From 77dc0c30c2089270d069597dcc6f2602240124dc Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:55:17 +0200 Subject: [PATCH 1/2] fix(web): cap terminal hyperlink hover range scan Hovering an explicit OSC 8 hyperlink walked every adjacent linked cell and decoded the full attacker-controlled URI once per cell. A crafted hyperlink with a large URI spanning wrapped rows decoded viewport x URI-length bytes per hover refresh, and render frames repeat that refresh while the pointer rests on the link: a 256 KiB URI on a 200x50 grid reaches ~2.5 GB of decodes and freezes or crashes the renderer. The range walk now lives in a pure helper capped at 4096 probed cells, and URIs above 4096 chars keep the single-cell hover behavior instead of expanding across the viewport. ox-alpha via opencode --- apps/web/src/terminal/ghostty/surface.test.ts | 107 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 86 +++++++++----- 2 files changed, 166 insertions(+), 27 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 45177a43a5be..3626612e2523 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -4,6 +4,8 @@ import type { GhosttyCell, GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, + MAX_HYPERLINK_RANGE_CELLS, + MAX_HYPERLINK_RANGE_URI_LENGTH, advanceTerminalSelectionClickSequence, applyTerminalCopyEvent, clearPrimedTerminalCopyInput, @@ -21,6 +23,7 @@ import { shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, + terminalExplicitHyperlinkRange, terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, @@ -91,6 +94,110 @@ describe("terminalGridCellAt", () => { }); }); +describe("terminalExplicitHyperlinkRange", () => { + const row = (over: Partial = {}): GhosttyRow => ({ + cells: [], + text: "", + isWrapContinuation: false, + wrapsToNext: false, + ...over, + }); + + const range = ( + cell: { x: number; y: number }, + linked: Set, + rowData: GhosttyRow[], + cols: number, + uri = "https://t3.codes", + ) => + terminalExplicitHyperlinkRange(cell, uri, { + cols, + rows: rowData.length, + rowData, + hasSameHyperlink: (x, y) => linked.has(`${x},${y}`), + }); + + it("expands over adjacent cells carrying the same hyperlink", () => { + expect(range({ x: 2, y: 0 }, new Set(["1,0", "2,0", "3,0"]), [row()], 5)).toEqual({ + start: { x: 1, y: 0 }, + end: { x: 3, y: 0 }, + }); + }); + + it("stops at cells without the hyperlink and at grid edges", () => { + expect(range({ x: 4, y: 0 }, new Set(["4,0", "5,0"]), [row()], 5)).toEqual({ + start: { x: 4, y: 0 }, + end: { x: 4, y: 0 }, + }); + expect(range({ x: 1, y: 0 }, new Set(["1,0"]), [row()], 5)).toEqual({ + start: { x: 1, y: 0 }, + end: { x: 1, y: 0 }, + }); + }); + + it("follows wrapped rows in both directions", () => { + const rowData = [ + row({ wrapsToNext: true }), + row({ wrapsToNext: true, isWrapContinuation: true }), + row({ isWrapContinuation: true }), + ]; + expect(range({ x: 3, y: 0 }, new Set(["3,0", "0,1", "1,1"]), rowData, 4)).toEqual({ + start: { x: 3, y: 0 }, + end: { x: 1, y: 1 }, + }); + expect(range({ x: 0, y: 2 }, new Set(["3,1", "0,2"]), rowData, 4)).toEqual({ + start: { x: 3, y: 1 }, + end: { x: 0, y: 2 }, + }); + }); + + it("truncates the walk at the scan cap when every probed cell matches", () => { + const cols = 8; + const rowCount = 600; + const rowData = Array.from({ length: rowCount }, (_, index) => + row({ + wrapsToNext: index < rowCount - 1, + isWrapContinuation: index > 0, + }), + ); + let probes = 0; + const result = terminalExplicitHyperlinkRange({ x: 0, y: 0 }, "https://t3.codes", { + cols, + rows: rowCount, + rowData, + hasSameHyperlink: () => { + probes += 1; + return true; + }, + }); + expect(probes).toBe(MAX_HYPERLINK_RANGE_CELLS); + expect(result.start).toEqual({ x: 0, y: 0 }); + expect(result.end).toEqual({ + x: MAX_HYPERLINK_RANGE_CELLS % cols, + y: Math.floor(MAX_HYPERLINK_RANGE_CELLS / cols), + }); + }); + + it("keeps oversized URIs to a single-cell range without probing neighbors", () => { + let probes = 0; + const result = terminalExplicitHyperlinkRange( + { x: 1, y: 1 }, + "a".repeat(MAX_HYPERLINK_RANGE_URI_LENGTH + 1), + { + cols: 4, + rows: 2, + rowData: [row(), row()], + hasSameHyperlink: () => { + probes += 1; + return true; + }, + }, + ); + expect(result).toEqual({ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } }); + expect(probes).toBe(0); + }); +}); + describe("shouldBlinkTerminalCursor", () => { const blinking = { focused: true, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index f390947c4210..0602d9ef3a2b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -230,6 +230,56 @@ export function terminalGridCellAt(options: { }; } +export const MAX_HYPERLINK_RANGE_URI_LENGTH = 4096; + +export const MAX_HYPERLINK_RANGE_CELLS = 4096; + +// The OSC 8 URI is attacker-controlled and unbounded, and every hyperlink +// probe decodes it in full, so the hover range walk is capped on both factors. +// Without the caps a single oversized hyperlink spanning the viewport makes +// each hover refresh allocate/decode viewport × URI-length bytes and freezes +// the renderer. +export function terminalExplicitHyperlinkRange( + cell: { x: number; y: number }, + uri: string, + options: { + cols: number; + rows: number; + rowData: GhosttySnapshot["rowData"]; + hasSameHyperlink: (x: number, y: number) => boolean; + }, +): GhosttyCellRange { + const start = { ...cell }; + const end = { ...cell }; + if (uri.length > MAX_HYPERLINK_RANGE_URI_LENGTH) return { start, end }; + let scanned = 0; + while (scanned < MAX_HYPERLINK_RANGE_CELLS) { + const previous = + start.x > 0 + ? { x: start.x - 1, y: start.y } + : start.y > 0 && options.rowData[start.y]?.isWrapContinuation + ? { x: options.cols - 1, y: start.y - 1 } + : null; + if (!previous || !options.hasSameHyperlink(previous.x, previous.y)) break; + scanned += 1; + start.x = previous.x; + start.y = previous.y; + } + while (scanned < MAX_HYPERLINK_RANGE_CELLS) { + const next = + end.x + 1 < options.cols + ? { x: end.x + 1, y: end.y } + : end.y + 1 < options.rows && options.rowData[end.y]?.wrapsToNext + ? { x: 0, y: end.y + 1 } + : null; + if (!next || !options.hasSameHyperlink(next.x, next.y)) break; + scanned += 1; + end.x = next.x; + end.y = next.y; + } + return { start, end }; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { const text = row.cells.map((cell) => cell.text || " ").join(""); return trimRight ? text.trimEnd() : text; @@ -1810,7 +1860,8 @@ export class GhosttyTerminalSurface { } private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { - if (!this.snapshot) return null; + const snapshot = this.snapshot; + if (!snapshot) return null; const cell = terminalGridCellAt({ bounds: this.canvas.getBoundingClientRect(), clientX, @@ -1824,36 +1875,17 @@ export class GhosttyTerminalSurface { if (!cell) return null; const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); if (explicitHyperlink) { - const start = { ...cell }; - const end = { ...cell }; - while (true) { - const previous = - start.x > 0 - ? { x: start.x - 1, y: start.y } - : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation - ? { x: this.cols - 1, y: start.y - 1 } - : null; - if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break; - start.x = previous.x; - start.y = previous.y; - } - while (true) { - const next = - end.x + 1 < this.cols - ? { x: end.x + 1, y: end.y } - : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext - ? { x: 0, y: end.y + 1 } - : null; - if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break; - end.x = next.x; - end.y = next.y; - } return { text: explicitHyperlink, - range: { start, end }, + range: terminalExplicitHyperlinkRange(cell, explicitHyperlink, { + cols: this.cols, + rows: this.rows, + rowData: snapshot.rowData, + hasSameHyperlink: (x, y) => this.core.hyperlinkAt(x, y) === explicitHyperlink, + }), }; } - return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); + return terminalLinkAtPositionWithRange(snapshot.rowData, cell.y, cell.x); } private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void { From fbb94ab5f8b3672bfaa239ae5ef5237e22d63a50 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:05:17 +0200 Subject: [PATCH 2/2] fix(web): count failed hyperlink probes toward the scan cap The range walk incremented its budget only on matching probes, so each walk could make one probe past MAX_HYPERLINK_RANGE_CELLS. Failed probes now consume budget before their result is evaluated, making the cap an exact bound on WASM hyperlink decodes per hover refresh. ox-alpha via opencode --- apps/web/src/terminal/ghostty/surface.test.ts | 27 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 6 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 3626612e2523..ecfdd29cb345 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -178,6 +178,33 @@ describe("terminalExplicitHyperlinkRange", () => { }); }); + it("counts failed probes toward the scan cap", () => { + const cols = 8; + const rowCount = 600; + const rowData = Array.from({ length: rowCount }, (_, index) => + row({ + wrapsToNext: index < rowCount - 1, + isWrapContinuation: index > 0, + }), + ); + let probes = 0; + const result = terminalExplicitHyperlinkRange({ x: 3, y: 0 }, "https://t3.codes", { + cols, + rows: rowCount, + rowData, + hasSameHyperlink: (x, y) => { + probes += 1; + return !(x === 2 && y === 0); + }, + }); + expect(probes).toBe(MAX_HYPERLINK_RANGE_CELLS); + expect(result.start).toEqual({ x: 3, y: 0 }); + expect(result.end).toEqual({ + x: (3 + MAX_HYPERLINK_RANGE_CELLS - 1) % cols, + y: Math.floor((3 + MAX_HYPERLINK_RANGE_CELLS - 1) / cols), + }); + }); + it("keeps oversized URIs to a single-cell range without probing neighbors", () => { let probes = 0; const result = terminalExplicitHyperlinkRange( diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0602d9ef3a2b..fc892b337cad 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -260,8 +260,9 @@ export function terminalExplicitHyperlinkRange( : start.y > 0 && options.rowData[start.y]?.isWrapContinuation ? { x: options.cols - 1, y: start.y - 1 } : null; - if (!previous || !options.hasSameHyperlink(previous.x, previous.y)) break; + if (!previous) break; scanned += 1; + if (!options.hasSameHyperlink(previous.x, previous.y)) break; start.x = previous.x; start.y = previous.y; } @@ -272,8 +273,9 @@ export function terminalExplicitHyperlinkRange( : end.y + 1 < options.rows && options.rowData[end.y]?.wrapsToNext ? { x: 0, y: end.y + 1 } : null; - if (!next || !options.hasSameHyperlink(next.x, next.y)) break; + if (!next) break; scanned += 1; + if (!options.hasSameHyperlink(next.x, next.y)) break; end.x = next.x; end.y = next.y; }