f429a992
fix: cell text carrying a control byte is refused at the decoder
a73x 2026-09-04 18:04
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -9172,3 +9172,35 @@ and a client must not depend on ghostty's page layout to paint a row. So mux | |||
| 9172 | keeps `dumpState` for the exec and cells for the wire, and the cell format is | 9172 | keeps `dumpState` for the exec and cells for the wire, and the cell format is |
| 9173 | mux's own: a client can be written against it without ghostty in the picture, | 9173 | mux's own: a client can be written against it without ghostty in the picture, |
| 9174 | which is the point of the change. | 9174 | which is the point of the change. |
| 9175 | |||
| 9176 | ## 2026-09-04 — cell text is filtered at the decoder, not at the painter | ||
| 9177 | |||
| 9178 | On main the client fed the daemon's bytes into its own ghostty engine and | ||
| 9179 | painted from that engine's cell dump. That engine was a sanitiser as a side | ||
| 9180 | effect: an escape sequence in the stream became at most a cell ghostty chose | ||
| 9181 | to keep, and nothing the daemon sent could reach the host terminal AS an | ||
| 9182 | escape. | ||
| 9183 | |||
| 9184 | The cells wire removed that engine. `paint.rowToVtFrom` writes a decoded | ||
| 9185 | cell's text between its own SGRs and `renderClipped` writes the result to the | ||
| 9186 | client's real terminal fd, so a cell's bytes now reach the user's emulator | ||
| 9187 | verbatim. A daemon could spell `\x1b]52;c;<base64>\x07` across the cells of a | ||
| 9188 | row — one byte per cell in an ascii run, or up to 63 in a head-form cluster — | ||
| 9189 | and the emulator would honour it: a clipboard write, an alt-screen flip, a | ||
| 9190 | title change. `CellRowWriter.cell` asserts only that a cluster fits in 63 | ||
| 9191 | bytes, and `asciiCell` gated the WRITER alone; the reader returned whatever | ||
| 9192 | byte was there. | ||
| 9193 | |||
| 9194 | The check lives in `CellRowReader.next`, so every consumer of the wire — the | ||
| 9195 | tui painter, the wasm core, muxa, any future one — is covered by construction | ||
| 9196 | rather than by each painter remembering. An ascii-run cell must satisfy | ||
| 9197 | `asciiByte` (0x20..0x7E), which is now one function the writer's run decision | ||
| 9198 | and the reader's refusal share; a head-form cluster must carry no byte below | ||
| 9199 | 0x20 and no 0x7F. Either is `error.BadPayload`, which becomes `.resync` on a | ||
| 9200 | delta and `error.SnapshotAborted` on a snapshot. | ||
| 9201 | |||
| 9202 | An honest ghostty page cell never holds a C0 or C1 byte, so this can only fire | ||
| 9203 | on a malformed or hostile daemon — which is exactly the case validating a | ||
| 9204 | payload is for. It is refused in the reader rather than stripped in the | ||
| 9205 | painter because a stripped row is a row the client and the daemon disagree | ||
| 9206 | about silently, and the dump-parity rule would go with it. | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -1360,8 +1360,27 @@ pub const cell_text_max = 63; | |||
| 1360 | 1360 | ||
| 1361 | pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 }; | 1361 | pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 }; |
| 1362 | 1362 | ||
| 1363 | /// The one printable-ascii predicate, shared by the writer's run decision and | ||
| 1364 | /// the reader's refusal so the two cannot drift apart. | ||
| 1365 | pub fn asciiByte(b: u8) bool { | ||
| 1366 | return b >= 0x20 and b <= 0x7E; | ||
| 1367 | } | ||
| 1368 | |||
| 1363 | fn asciiCell(wide: Wide, text: []const u8) bool { | 1369 | fn asciiCell(wide: Wide, text: []const u8) bool { |
| 1364 | return wide == .narrow and text.len == 1 and text[0] >= 0x20 and text[0] <= 0x7E; | 1370 | return wide == .narrow and text.len == 1 and asciiByte(text[0]); |
| 1371 | } | ||
| 1372 | |||
| 1373 | /// Whether a head-form cell's text is a cluster no terminal will ACT on. | ||
| 1374 | /// | ||
| 1375 | /// The client writes a cell's text straight to the user's real terminal | ||
| 1376 | /// (`paint.rowToVtFrom`), and there is no VT parser on the client side any | ||
| 1377 | /// more to absorb what the daemon sent. So a control byte in a cell is | ||
| 1378 | /// refused HERE, in the reader, where every consumer of the wire is covered | ||
| 1379 | /// at once: an honest ghostty grid never holds one, and a daemon that sends | ||
| 1380 | /// one is spelling an escape sequence across cells onto somebody's screen. | ||
| 1381 | fn plainText(text: []const u8) bool { | ||
| 1382 | for (text) |b| if (b < 0x20 or b == 0x7F) return false; | ||
| 1383 | return true; | ||
| 1365 | } | 1384 | } |
| 1366 | 1385 | ||
| 1367 | pub const CellRowWriter = struct { | 1386 | pub const CellRowWriter = struct { |
| @@ -1560,6 +1579,10 @@ pub const CellRowReader = struct { | |||
| 1560 | self.read += 1; | 1579 | self.read += 1; |
| 1561 | if (self.run_ascii) { | 1580 | if (self.run_ascii) { |
| 1562 | if (self.rest.len < 1) return error.BadPayload; | 1581 | if (self.rest.len < 1) return error.BadPayload; |
| 1582 | // The same predicate the writer applies before it opens an ascii | ||
| 1583 | // run: a byte outside it in an ascii run is not a cell any encoder | ||
| 1584 | // of ours produced. | ||
| 1585 | if (!asciiByte(self.rest[0])) return error.BadPayload; | ||
| 1563 | const text = self.rest[0..1]; | 1586 | const text = self.rest[0..1]; |
| 1564 | self.rest = self.rest[1..]; | 1587 | self.rest = self.rest[1..]; |
| 1565 | return .{ .style = self.cur, .wide = .narrow, .text = text }; | 1588 | return .{ .style = self.cur, .wide = .narrow, .text = text }; |
| @@ -1569,6 +1592,7 @@ pub const CellRowReader = struct { | |||
| 1569 | const len: usize = head & 0x3f; | 1592 | const len: usize = head & 0x3f; |
| 1570 | if (self.rest.len < 1 + len) return error.BadPayload; | 1593 | if (self.rest.len < 1 + len) return error.BadPayload; |
| 1571 | const text = self.rest[1 .. 1 + len]; | 1594 | const text = self.rest[1 .. 1 + len]; |
| 1595 | if (!plainText(text)) return error.BadPayload; | ||
| 1572 | self.rest = self.rest[1 + len ..]; | 1596 | self.rest = self.rest[1 + len ..]; |
| 1573 | return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text }; | 1597 | return .{ .style = self.cur, .wide = @enumFromInt(head >> 6), .text = text }; |
| 1574 | } | 1598 | } |
| @@ -2895,6 +2919,60 @@ test "cellrow: two default ascii cells are one ascii run" { | |||
| 2895 | }, list.items); | 2919 | }, list.items); |
| 2896 | } | 2920 | } |
| 2897 | 2921 | ||
| 2922 | test "cellrow: an ascii run carrying a control byte is refused" { | ||
| 2923 | // The wire is hand-buildable by whatever is on the far side, so the run | ||
| 2924 | // header can claim ascii over a byte the writer would never have put | ||
| 2925 | // there. The reader applies the writer's own predicate rather than | ||
| 2926 | // trusting the flag. | ||
| 2927 | const alloc = std.testing.allocator; | ||
| 2928 | var list: std.ArrayList(u8) = .empty; | ||
| 2929 | defer list.deinit(alloc); | ||
| 2930 | try list.appendSlice(alloc, &[_]u8{ 1, 0, 1, 0, 0x80, 0x1b }); | ||
| 2931 | var r = try CellRowReader.init(list.items); | ||
| 2932 | try std.testing.expectError(error.BadPayload, r.next()); | ||
| 2933 | } | ||
| 2934 | |||
| 2935 | test "cellrow: a head-form cell whose cluster holds a control byte is refused" { | ||
| 2936 | const alloc = std.testing.allocator; | ||
| 2937 | var list: std.ArrayList(u8) = .empty; | ||
| 2938 | defer list.deinit(alloc); | ||
| 2939 | // ncells=1; one non-ascii run; head byte says narrow, 2 bytes; "\x1b[". | ||
| 2940 | try list.appendSlice(alloc, &[_]u8{ 1, 0, 1, 0, 0x00, 0x02, 0x1b, '[' }); | ||
| 2941 | var r = try CellRowReader.init(list.items); | ||
| 2942 | try std.testing.expectError(error.BadPayload, r.next()); | ||
| 2943 | } | ||
| 2944 | |||
| 2945 | test "cellrow: an OSC 52 spelled across cells never decodes into a row" { | ||
| 2946 | // The trust boundary this check exists for. The client has no VT parser: | ||
| 2947 | // paint.rowToVtFrom writes a cell's text to the user's real terminal | ||
| 2948 | // verbatim, so a daemon that spread an escape sequence one byte per cell | ||
| 2949 | // would be typing on that user's screen — here, a clipboard write. The | ||
| 2950 | // row must be refused, not decoded. | ||
| 2951 | const alloc = std.testing.allocator; | ||
| 2952 | var list: std.ArrayList(u8) = .empty; | ||
| 2953 | defer list.deinit(alloc); | ||
| 2954 | const osc = "\x1b]52;c;bXV4\x07"; | ||
| 2955 | try list.appendSlice(alloc, &[_]u8{ @intCast(osc.len), 0 }); | ||
| 2956 | try list.appendSlice(alloc, &[_]u8{ @intCast(osc.len), 0, 0x80 }); | ||
| 2957 | try list.appendSlice(alloc, osc); | ||
| 2958 | |||
| 2959 | var r = try CellRowReader.init(list.items); | ||
| 2960 | var decoded: usize = 0; | ||
| 2961 | const refused = while (decoded <= osc.len) { | ||
| 2962 | const cell_or_end = r.next() catch break true; | ||
| 2963 | if (cell_or_end == null) break false; | ||
| 2964 | decoded += 1; | ||
| 2965 | } else false; | ||
| 2966 | if (!refused) { | ||
| 2967 | std.debug.print( | ||
| 2968 | "an OSC 52 clipboard write spelled across {d} cells decoded as a row; " ++ | ||
| 2969 | "the client would write those bytes to the user's terminal\n", | ||
| 2970 | .{decoded}, | ||
| 2971 | ); | ||
| 2972 | return error.EscapeSequenceDecoded; | ||
| 2973 | } | ||
| 2974 | } | ||
| 2975 | |||
| 2898 | test "cellrow: the writer owns each cell's text, so a reused caller buffer is safe" { | 2976 | test "cellrow: the writer owns each cell's text, so a reused caller buffer is safe" { |
| 2899 | const alloc = std.testing.allocator; | 2977 | const alloc = std.testing.allocator; |
| 2900 | var list: std.ArrayList(u8) = .empty; | 2978 | var list: std.ArrayList(u8) = .empty; |