Skip to content

Commit 172c883

Browse files
fix: enforce title stack limit and add CSI 14t/16t handlers
- Cap windowTitleStack and iconNameStack to 10 entries (matching upstream STACK_LIMIT) to prevent unbounded memory growth from malicious input. - Handle CSI 14 t (GetWinSizePixels) and CSI 16 t (GetCellSizePixels) by firing OnRequestWindowsOptionsReport instead of silently ignoring them. - Add WindowsOptionsReportType enum and expose the event on Terminal. Fixes #33 Co-authored-by: Ona <no-reply@ona.com>
1 parent b710f1e commit 172c883

5 files changed

Lines changed: 293 additions & 5 deletions

File tree

inputhandler.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ type InputHandler struct {
9898
OnRequestBellEmitter EventEmitter[struct{}]
9999
OnRequestResetEmitter EventEmitter[struct{}]
100100
OnRequestRefreshRowsEmitter EventEmitter[RowRange]
101-
OnColorEmitter EventEmitter[[]ColorEvent]
102-
OnRequestSyncScrollBarEmitter EventEmitter[struct{}]
103-
OnRequestColorSchemeQueryEmitter EventEmitter[struct{}]
101+
OnColorEmitter EventEmitter[[]ColorEvent]
102+
OnRequestSyncScrollBarEmitter EventEmitter[struct{}]
103+
OnRequestColorSchemeQueryEmitter EventEmitter[struct{}]
104+
OnRequestWindowsOptionsReportEmitter EventEmitter[WindowsOptionsReportType]
104105
}
105106

106107
// NewInputHandler creates an InputHandler and registers all parser handlers.
@@ -549,4 +550,5 @@ func (h *InputHandler) Dispose() {
549550
h.OnColorEmitter.Dispose()
550551
h.OnRequestSyncScrollBarEmitter.Dispose()
551552
h.OnRequestColorSchemeQueryEmitter.Dispose()
553+
h.OnRequestWindowsOptionsReportEmitter.Dispose()
552554
}

inputhandler_csi.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import (
55
"strings"
66
)
77

8+
// titleStackLimit is the maximum number of entries in the title/icon name push
9+
// stacks. Matches upstream xterm.js STACK_LIMIT (InputHandler.ts:47).
10+
const titleStackLimit = 10
11+
812
// moveCursor moves the cursor by a relative offset, clamping to valid range.
913
func (h *InputHandler) moveCursor(x, y int) {
1014
h.restrictCursor()
@@ -946,9 +950,15 @@ func (h *InputHandler) windowOptions(params *Params) bool {
946950
}
947951
if ps2 == 0 || ps2 == 2 {
948952
h.windowTitleStack = append(h.windowTitleStack, h.windowTitle)
953+
if len(h.windowTitleStack) > titleStackLimit {
954+
h.windowTitleStack = h.windowTitleStack[len(h.windowTitleStack)-titleStackLimit:]
955+
}
949956
}
950957
if ps2 == 0 || ps2 == 1 {
951958
h.iconNameStack = append(h.iconNameStack, h.iconName)
959+
if len(h.iconNameStack) > titleStackLimit {
960+
h.iconNameStack = h.iconNameStack[len(h.iconNameStack)-titleStackLimit:]
961+
}
952962
}
953963
case 23:
954964
// Pop title from stack.
@@ -972,8 +982,21 @@ func (h *InputHandler) windowOptions(params *Params) bool {
972982
h.OnIconNameChangeEmitter.Fire(name)
973983
}
974984
}
985+
case 14:
986+
// Report window size in pixels. Ps2 == 2 means cell size (handled by case 16 upstream),
987+
// otherwise report window size.
988+
ps2 := int32(0)
989+
if params.Length >= 2 {
990+
ps2 = params.Params[1]
991+
}
992+
if ps2 != 2 {
993+
h.OnRequestWindowsOptionsReportEmitter.Fire(GetWinSizePixels)
994+
}
995+
case 16:
996+
// Report cell size in pixels.
997+
h.OnRequestWindowsOptionsReportEmitter.Fire(GetCellSizePixels)
975998
default:
976-
// Other sub-commands (14, 16, etc.) are renderer-specific; silently ignore.
999+
// Other sub-commands are renderer-specific; silently ignore.
9771000
}
9781001
return true
9791002
}

inputhandler_csi_test.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,4 +1592,249 @@ func TestDSR996ColorSchemeQuery(t *testing.T) {
15921592
})
15931593
}
15941594

1595+
func TestWindowOptionsTitleStackLimit(t *testing.T) {
1596+
t.Parallel()
1597+
1598+
t.Run("title_stack_capped_at_10", func(t *testing.T) {
1599+
t.Parallel()
1600+
h := newTestInputHandler(80, 24)
1601+
1602+
// Set and push 15 titles.
1603+
for i := 0; i < 15; i++ {
1604+
h.ParseString("\x1b]2;title\x07")
1605+
h.ParseString("\x1b[22;2t") // push title
1606+
}
1607+
1608+
// Pop all — should get at most 10.
1609+
popCount := 0
1610+
for i := 0; i < 15; i++ {
1611+
fired := false
1612+
d := h.OnTitleChangeEmitter.Event(func(s string) { fired = true })
1613+
h.ParseString("\x1b[23;2t") // pop title
1614+
if fired {
1615+
popCount++
1616+
}
1617+
d.Dispose()
1618+
}
1619+
if popCount != titleStackLimit {
1620+
t.Errorf("popped %d titles, want exactly %d", popCount, titleStackLimit)
1621+
}
1622+
})
1623+
1624+
t.Run("icon_name_stack_capped_at_10", func(t *testing.T) {
1625+
t.Parallel()
1626+
h := newTestInputHandler(80, 24)
1627+
1628+
// Set and push 15 icon names.
1629+
for i := 0; i < 15; i++ {
1630+
h.ParseString("\x1b]1;icon\x07")
1631+
h.ParseString("\x1b[22;1t") // push icon name
1632+
}
1633+
1634+
// Pop all — should get at most 10.
1635+
popCount := 0
1636+
for i := 0; i < 15; i++ {
1637+
fired := false
1638+
d := h.OnIconNameChangeEmitter.Event(func(s string) { fired = true })
1639+
h.ParseString("\x1b[23;1t") // pop icon name
1640+
if fired {
1641+
popCount++
1642+
}
1643+
d.Dispose()
1644+
}
1645+
if popCount != titleStackLimit {
1646+
t.Errorf("popped %d icon names, want exactly %d", popCount, titleStackLimit)
1647+
}
1648+
})
1649+
1650+
t.Run("both_stacks_capped_at_10", func(t *testing.T) {
1651+
t.Parallel()
1652+
h := newTestInputHandler(80, 24)
1653+
1654+
// Push 12 entries for both title and icon name (ps2=0).
1655+
for i := 0; i < 12; i++ {
1656+
h.ParseString("\x1b]0;both\x07") // OSC 0 sets both
1657+
h.ParseString("\x1b[22;0t") // push both
1658+
}
1659+
1660+
titlePops := 0
1661+
iconPops := 0
1662+
for i := 0; i < 12; i++ {
1663+
tFired := false
1664+
iFired := false
1665+
dt := h.OnTitleChangeEmitter.Event(func(s string) { tFired = true })
1666+
di := h.OnIconNameChangeEmitter.Event(func(s string) { iFired = true })
1667+
h.ParseString("\x1b[23;0t") // pop both
1668+
if tFired {
1669+
titlePops++
1670+
}
1671+
if iFired {
1672+
iconPops++
1673+
}
1674+
dt.Dispose()
1675+
di.Dispose()
1676+
}
1677+
if titlePops != titleStackLimit {
1678+
t.Errorf("title pops = %d, want %d", titlePops, titleStackLimit)
1679+
}
1680+
if iconPops != titleStackLimit {
1681+
t.Errorf("icon pops = %d, want %d", iconPops, titleStackLimit)
1682+
}
1683+
})
1684+
1685+
t.Run("stack_preserves_most_recent_entries", func(t *testing.T) {
1686+
t.Parallel()
1687+
h := newTestInputHandler(80, 24)
1688+
1689+
// Push 12 titles with distinct values; the first 2 should be evicted.
1690+
for i := 0; i < 12; i++ {
1691+
h.ParseString("\x1b]2;t" + string(rune('A'+i)) + "\x07")
1692+
h.ParseString("\x1b[22;2t")
1693+
}
1694+
1695+
// Pop returns from top of stack (most recent push first).
1696+
// Pushed: tA tB tC tD tE tF tG tH tI tJ tK tL
1697+
// After trim: tC tD tE tF tG tH tI tJ tK tL (oldest 2 evicted)
1698+
// Pop order: tL, tK, tJ, ...
1699+
var got string
1700+
d := h.OnTitleChangeEmitter.Event(func(s string) { got = s })
1701+
h.ParseString("\x1b[23;2t")
1702+
d.Dispose()
1703+
1704+
expected := "tL"
1705+
if got != expected {
1706+
t.Errorf("first pop = %q, want %q", got, expected)
1707+
}
1708+
})
1709+
}
1710+
1711+
func TestWindowOptionsCSI14t(t *testing.T) {
1712+
t.Parallel()
1713+
1714+
t.Run("fires_GetWinSizePixels", func(t *testing.T) {
1715+
t.Parallel()
1716+
h := newTestInputHandler(80, 24)
1717+
1718+
var got WindowsOptionsReportType
1719+
fired := false
1720+
d := h.OnRequestWindowsOptionsReportEmitter.Event(func(rt WindowsOptionsReportType) {
1721+
fired = true
1722+
got = rt
1723+
})
1724+
defer d.Dispose()
1725+
1726+
h.ParseString("\x1b[14t")
1727+
if !fired {
1728+
t.Fatal("OnRequestWindowsOptionsReport not fired for CSI 14 t")
1729+
}
1730+
if got != GetWinSizePixels {
1731+
t.Errorf("report type = %d, want GetWinSizePixels (%d)", got, GetWinSizePixels)
1732+
}
1733+
})
1734+
1735+
t.Run("ps2_not_2_fires_GetWinSizePixels", func(t *testing.T) {
1736+
t.Parallel()
1737+
h := newTestInputHandler(80, 24)
1738+
1739+
fired := false
1740+
d := h.OnRequestWindowsOptionsReportEmitter.Event(func(rt WindowsOptionsReportType) {
1741+
fired = true
1742+
})
1743+
defer d.Dispose()
1744+
1745+
h.ParseString("\x1b[14;1t")
1746+
if !fired {
1747+
t.Fatal("OnRequestWindowsOptionsReport not fired for CSI 14;1 t")
1748+
}
1749+
})
1750+
1751+
t.Run("ps2_eq_2_does_not_fire", func(t *testing.T) {
1752+
t.Parallel()
1753+
h := newTestInputHandler(80, 24)
1754+
1755+
fired := false
1756+
d := h.OnRequestWindowsOptionsReportEmitter.Event(func(rt WindowsOptionsReportType) {
1757+
fired = true
1758+
})
1759+
defer d.Dispose()
1760+
1761+
h.ParseString("\x1b[14;2t")
1762+
if fired {
1763+
t.Error("OnRequestWindowsOptionsReport should not fire for CSI 14;2 t")
1764+
}
1765+
})
1766+
}
1767+
1768+
func TestWindowOptionsCSI16t(t *testing.T) {
1769+
t.Parallel()
1770+
1771+
t.Run("fires_GetCellSizePixels", func(t *testing.T) {
1772+
t.Parallel()
1773+
h := newTestInputHandler(80, 24)
1774+
1775+
var got WindowsOptionsReportType
1776+
fired := false
1777+
d := h.OnRequestWindowsOptionsReportEmitter.Event(func(rt WindowsOptionsReportType) {
1778+
fired = true
1779+
got = rt
1780+
})
1781+
defer d.Dispose()
1782+
1783+
h.ParseString("\x1b[16t")
1784+
if !fired {
1785+
t.Fatal("OnRequestWindowsOptionsReport not fired for CSI 16 t")
1786+
}
1787+
if got != GetCellSizePixels {
1788+
t.Errorf("report type = %d, want GetCellSizePixels (%d)", got, GetCellSizePixels)
1789+
}
1790+
})
1791+
}
1792+
1793+
func TestWindowOptionsReportTerminalLevel(t *testing.T) {
1794+
t.Parallel()
1795+
1796+
t.Run("CSI_14t_forwarded_to_terminal", func(t *testing.T) {
1797+
t.Parallel()
1798+
term := newTestTerminal(80, 24)
1799+
defer term.Dispose()
1800+
1801+
var got WindowsOptionsReportType
1802+
fired := false
1803+
d := term.OnRequestWindowsOptionsReport(func(rt WindowsOptionsReportType) {
1804+
fired = true
1805+
got = rt
1806+
})
1807+
defer d.Dispose()
1808+
1809+
term.WriteString("\x1b[14t")
1810+
if !fired {
1811+
t.Fatal("Terminal.OnRequestWindowsOptionsReport not fired for CSI 14 t")
1812+
}
1813+
if got != GetWinSizePixels {
1814+
t.Errorf("report type = %d, want GetWinSizePixels (%d)", got, GetWinSizePixels)
1815+
}
1816+
})
1817+
1818+
t.Run("CSI_16t_forwarded_to_terminal", func(t *testing.T) {
1819+
t.Parallel()
1820+
term := newTestTerminal(80, 24)
1821+
defer term.Dispose()
1822+
1823+
var got WindowsOptionsReportType
1824+
fired := false
1825+
d := term.OnRequestWindowsOptionsReport(func(rt WindowsOptionsReportType) {
1826+
fired = true
1827+
got = rt
1828+
})
1829+
defer d.Dispose()
1830+
1831+
term.WriteString("\x1b[16t")
1832+
if !fired {
1833+
t.Fatal("Terminal.OnRequestWindowsOptionsReport not fired for CSI 16 t")
1834+
}
1835+
if got != GetCellSizePixels {
1836+
t.Errorf("report type = %d, want GetCellSizePixels (%d)", got, GetCellSizePixels)
1837+
}
1838+
})
1839+
}
15951840

terminal.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ type Terminal struct {
4848
OnResizeEmitter EventEmitter[BufferResizeEvent]
4949
OnScrollEmitter EventEmitter[int]
5050
OnRenderEmitter EventEmitter[RowRange]
51-
OnRequestColorSchemeQueryEmitter EventEmitter[struct{}]
51+
OnRequestColorSchemeQueryEmitter EventEmitter[struct{}]
52+
OnRequestWindowsOptionsReportEmitter EventEmitter[WindowsOptionsReportType]
5253
}
5354

5455
// New creates a new Terminal with the given options.
@@ -86,6 +87,7 @@ func New(opts ...Option) *Terminal {
8687
ih.OnCursorMoveEmitter.Event(func(struct{}) { t.OnCursorMoveEmitter.Fire(struct{}{}) })
8788
ih.OnRequestRefreshRowsEmitter.Event(func(r RowRange) { t.OnRenderEmitter.Fire(r) })
8889
ih.OnRequestColorSchemeQueryEmitter.Event(func(struct{}) { t.OnRequestColorSchemeQueryEmitter.Fire(struct{}{}) })
90+
ih.OnRequestWindowsOptionsReportEmitter.Event(func(rt WindowsOptionsReportType) { t.OnRequestWindowsOptionsReportEmitter.Fire(rt) })
8991

9092
// Forward buffer service events.
9193
bufSvc.OnResizeEmitter.Event(func(e BufferResizeEvent) { t.OnResizeEmitter.Fire(e) })
@@ -260,6 +262,11 @@ func (t *Terminal) OnRequestColorSchemeQuery(fn func()) Disposable {
260262
return t.OnRequestColorSchemeQueryEmitter.Event(func(struct{}) { fn() })
261263
}
262264

265+
// OnRequestWindowsOptionsReport subscribes to window-options report requests (CSI 14 t, CSI 16 t).
266+
func (t *Terminal) OnRequestWindowsOptionsReport(fn func(WindowsOptionsReportType)) Disposable {
267+
return t.OnRequestWindowsOptionsReportEmitter.Event(fn)
268+
}
269+
263270
// OnColor subscribes to color palette query/set/restore events (OSC 4/10/11/12).
264271
func (t *Terminal) OnColor(fn func([]ColorEvent)) Disposable {
265272
return t.inputHandler.OnColorEmitter.Event(fn)
@@ -410,4 +417,5 @@ func (t *Terminal) Dispose() {
410417
t.OnScrollEmitter.Dispose()
411418
t.OnRenderEmitter.Dispose()
412419
t.OnRequestColorSchemeQueryEmitter.Dispose()
420+
t.OnRequestWindowsOptionsReportEmitter.Dispose()
413421
}

types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ type DeleteEvent struct {
177177
// BufferIndex denotes a position in the buffer: [rowIndex, colIndex].
178178
type BufferIndex [2]int
179179

180+
// WindowsOptionsReportType identifies a window-options report request (CSI t).
181+
type WindowsOptionsReportType int
182+
183+
const (
184+
// GetWinSizePixels requests the window size in pixels (CSI 14 t).
185+
GetWinSizePixels WindowsOptionsReportType = 0
186+
// GetCellSizePixels requests the cell size in pixels (CSI 16 t).
187+
GetCellSizePixels WindowsOptionsReportType = 1
188+
)
189+
180190
// KittyKeyboardState tracks the kitty keyboard protocol state.
181191
type KittyKeyboardState struct {
182192
Flags int

0 commit comments

Comments
 (0)