Skip to content

Commit f433168

Browse files
fix: gate RegisterCsiHandler for CSI t with WindowOptions permission check
Wrap user-provided CSI t handlers registered via RegisterCsiHandler with a permission check against the terminal's WindowOptions configuration. Without this gate, custom handlers were called for all window option parameters regardless of configuration. Add WindowOptions struct, paramToWindowOption helper, and WithWindowOptions option constructor. The default WindowOptions denies all sub-commands, matching upstream xterm.js behavior. Fixes #40 Co-authored-by: Ona <no-reply@ona.com>
1 parent ea05ced commit f433168

4 files changed

Lines changed: 250 additions & 0 deletions

File tree

options.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,89 @@ type WindowsPty struct {
3737
BuildNo int `json:"buildNumber,omitempty"`
3838
}
3939

40+
// WindowOptions controls which CSI t (window manipulation) sub-commands are
41+
// permitted. All fields default to false (denied). Mirrors upstream xterm.js
42+
// IWindowOptions interface.
43+
type WindowOptions struct {
44+
RestoreWin bool `json:"restoreWin,omitempty"`
45+
MinimizeWin bool `json:"minimizeWin,omitempty"`
46+
SetWinPosition bool `json:"setWinPosition,omitempty"`
47+
SetWinSizePixels bool `json:"setWinSizePixels,omitempty"`
48+
RaiseWin bool `json:"raiseWin,omitempty"`
49+
LowerWin bool `json:"lowerWin,omitempty"`
50+
RefreshWin bool `json:"refreshWin,omitempty"`
51+
SetWinSizeChars bool `json:"setWinSizeChars,omitempty"`
52+
MaximizeWin bool `json:"maximizeWin,omitempty"`
53+
FullscreenWin bool `json:"fullscreenWin,omitempty"`
54+
GetWinState bool `json:"getWinState,omitempty"`
55+
GetWinPosition bool `json:"getWinPosition,omitempty"`
56+
GetWinSizePixels bool `json:"getWinSizePixels,omitempty"`
57+
GetScreenSizePixels bool `json:"getScreenSizePixels,omitempty"`
58+
GetCellSizePixels bool `json:"getCellSizePixels,omitempty"`
59+
GetWinSizeChars bool `json:"getWinSizeChars,omitempty"`
60+
GetScreenSizeChars bool `json:"getScreenSizeChars,omitempty"`
61+
GetIconTitle bool `json:"getIconTitle,omitempty"`
62+
GetWinTitle bool `json:"getWinTitle,omitempty"`
63+
PushTitle bool `json:"pushTitle,omitempty"`
64+
PopTitle bool `json:"popTitle,omitempty"`
65+
SetWinLines bool `json:"setWinLines,omitempty"`
66+
}
67+
68+
// paramToWindowOption checks whether the given CSI t sub-command parameter is
69+
// allowed by the WindowOptions configuration.
70+
func paramToWindowOption(n int32, opts WindowOptions) bool {
71+
if n > 24 {
72+
return opts.SetWinLines
73+
}
74+
switch n {
75+
case 1:
76+
return opts.RestoreWin
77+
case 2:
78+
return opts.MinimizeWin
79+
case 3:
80+
return opts.SetWinPosition
81+
case 4:
82+
return opts.SetWinSizePixels
83+
case 5:
84+
return opts.RaiseWin
85+
case 6:
86+
return opts.LowerWin
87+
case 7:
88+
return opts.RefreshWin
89+
case 8:
90+
return opts.SetWinSizeChars
91+
case 9:
92+
return opts.MaximizeWin
93+
case 10:
94+
return opts.FullscreenWin
95+
case 11:
96+
return opts.GetWinState
97+
case 13:
98+
return opts.GetWinPosition
99+
case 14:
100+
return opts.GetWinSizePixels
101+
case 15:
102+
return opts.GetScreenSizePixels
103+
case 16:
104+
return opts.GetCellSizePixels
105+
case 18:
106+
return opts.GetWinSizeChars
107+
case 19:
108+
return opts.GetScreenSizeChars
109+
case 20:
110+
return opts.GetIconTitle
111+
case 21:
112+
return opts.GetWinTitle
113+
case 22:
114+
return opts.PushTitle
115+
case 23:
116+
return opts.PopTitle
117+
case 24:
118+
return opts.SetWinLines
119+
}
120+
return false
121+
}
122+
40123
// VtExtensions gates non-standard terminal extensions.
41124
// Mirrors upstream xterm.js vtExtensions option.
42125
// Pointer fields default to true when nil.
@@ -104,6 +187,7 @@ type TerminalOptions struct {
104187
ConvertEol bool `json:"convertEol"`
105188
TermName string `json:"termName"`
106189
WindowsPty WindowsPty `json:"windowsPty"`
190+
WindowOptions WindowOptions `json:"windowOptions"`
107191
VtExtensions VtExtensions `json:"vtExtensions"`
108192
}
109193

@@ -227,6 +311,7 @@ func (s *OptionsService) applyOverrides(opts *TerminalOptions) {
227311
s.Options.MacOptionIsMeta = opts.MacOptionIsMeta
228312
s.Options.AltClickMovesCursor = opts.AltClickMovesCursor
229313
s.Options.WindowsPty = opts.WindowsPty
314+
s.Options.WindowOptions = opts.WindowOptions
230315
s.Options.VtExtensions = opts.VtExtensions
231316
}
232317

options_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,53 @@ func TestOptionsServiceSetOptionConvertEol(t *testing.T) {
317317
t.Errorf("(-want +got):\n%s", diff)
318318
}
319319
}
320+
321+
func TestParamToWindowOption(t *testing.T) {
322+
t.Parallel()
323+
324+
type TestCase struct {
325+
Name string
326+
Param int32
327+
Opts WindowOptions
328+
Allowed bool
329+
}
330+
tests := []TestCase{
331+
{"default denies all", 18, WindowOptions{}, false},
332+
{"getWinSizeChars allowed", 18, WindowOptions{GetWinSizeChars: true}, true},
333+
{"restoreWin allowed", 1, WindowOptions{RestoreWin: true}, true},
334+
{"minimizeWin allowed", 2, WindowOptions{MinimizeWin: true}, true},
335+
{"setWinPosition allowed", 3, WindowOptions{SetWinPosition: true}, true},
336+
{"setWinSizePixels allowed", 4, WindowOptions{SetWinSizePixels: true}, true},
337+
{"raiseWin allowed", 5, WindowOptions{RaiseWin: true}, true},
338+
{"lowerWin allowed", 6, WindowOptions{LowerWin: true}, true},
339+
{"refreshWin allowed", 7, WindowOptions{RefreshWin: true}, true},
340+
{"setWinSizeChars allowed", 8, WindowOptions{SetWinSizeChars: true}, true},
341+
{"maximizeWin allowed", 9, WindowOptions{MaximizeWin: true}, true},
342+
{"fullscreenWin allowed", 10, WindowOptions{FullscreenWin: true}, true},
343+
{"getWinState allowed", 11, WindowOptions{GetWinState: true}, true},
344+
{"getWinPosition allowed", 13, WindowOptions{GetWinPosition: true}, true},
345+
{"getWinSizePixels allowed", 14, WindowOptions{GetWinSizePixels: true}, true},
346+
{"getScreenSizePixels allowed", 15, WindowOptions{GetScreenSizePixels: true}, true},
347+
{"getCellSizePixels allowed", 16, WindowOptions{GetCellSizePixels: true}, true},
348+
{"getScreenSizeChars allowed", 19, WindowOptions{GetScreenSizeChars: true}, true},
349+
{"getIconTitle allowed", 20, WindowOptions{GetIconTitle: true}, true},
350+
{"getWinTitle allowed", 21, WindowOptions{GetWinTitle: true}, true},
351+
{"pushTitle allowed", 22, WindowOptions{PushTitle: true}, true},
352+
{"popTitle allowed", 23, WindowOptions{PopTitle: true}, true},
353+
{"setWinLines allowed", 24, WindowOptions{SetWinLines: true}, true},
354+
{"param > 24 uses setWinLines", 25, WindowOptions{SetWinLines: true}, true},
355+
{"param > 24 denied without setWinLines", 30, WindowOptions{}, false},
356+
{"unmapped param 12 denied", 12, WindowOptions{}, false},
357+
{"wrong option for param", 14, WindowOptions{GetCellSizePixels: true}, false},
358+
}
359+
for _, tc := range tests {
360+
t.Run(tc.Name, func(t *testing.T) {
361+
t.Parallel()
362+
got := paramToWindowOption(tc.Param, tc.Opts)
363+
if got != tc.Allowed {
364+
t.Errorf("paramToWindowOption(%d, ...) = %v, want %v", tc.Param, got, tc.Allowed)
365+
}
366+
})
367+
}
368+
}
369+

terminal.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func WithScreenReaderMode(on bool) Option {
2828
return func(o *TerminalOptions) { o.ScreenReaderMode = on }
2929
}
3030

31+
// WithWindowOptions configures which CSI t sub-commands are permitted.
32+
func WithWindowOptions(wo WindowOptions) Option {
33+
return func(o *TerminalOptions) { o.WindowOptions = wo }
34+
}
35+
3136
// WithVtExtensions configures non-standard VT extensions.
3237
func WithVtExtensions(ext VtExtensions) Option {
3338
return func(o *TerminalOptions) { o.VtExtensions = ext }
@@ -309,7 +314,17 @@ func (t *Terminal) RegisterApcHandler(id FunctionIdentifier, handler func(data s
309314

310315
// RegisterCsiHandler registers a custom handler for a CSI escape sequence.
311316
// The handler returns true to stop the handler chain from bubbling further.
317+
// For CSI t (window options), the handler is wrapped with a permission check
318+
// against the terminal's WindowOptions configuration.
312319
func (t *Terminal) RegisterCsiHandler(id FunctionIdentifier, handler CsiHandler) Disposable {
320+
if id.Final == 't' && id.Prefix == 0 && id.Intermediates == "" {
321+
return t.inputHandler.parser.RegisterCsiHandler(id, func(params *Params) bool {
322+
if !paramToWindowOption(params.Params[0], t.optionsService.Options.WindowOptions) {
323+
return true
324+
}
325+
return handler(params)
326+
})
327+
}
313328
return t.inputHandler.parser.RegisterCsiHandler(id, handler)
314329
}
315330

terminal_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,106 @@ func TestTerminalRegisterCsiHandler(t *testing.T) {
773773
}
774774
}
775775

776+
func TestTerminalRegisterCsiHandlerWindowOptionGate(t *testing.T) {
777+
t.Parallel()
778+
779+
t.Run("blocked when window option not permitted", func(t *testing.T) {
780+
t.Parallel()
781+
// Default WindowOptions has all fields false — handler should be blocked.
782+
term := New(WithCols(80), WithRows(24))
783+
784+
var called bool
785+
term.RegisterCsiHandler(FunctionIdentifier{Final: 't'}, func(params *Params) bool {
786+
called = true
787+
return true
788+
})
789+
790+
// Send CSI 18 t (getWinSizeChars)
791+
term.WriteString("\x1b[18t")
792+
if called {
793+
t.Fatal("CSI t handler was called despite window option not being permitted")
794+
}
795+
})
796+
797+
t.Run("allowed when window option is permitted", func(t *testing.T) {
798+
t.Parallel()
799+
term := New(WithCols(80), WithRows(24), WithWindowOptions(WindowOptions{
800+
GetWinSizeChars: true,
801+
}))
802+
803+
var called bool
804+
var gotParam int32
805+
term.RegisterCsiHandler(FunctionIdentifier{Final: 't'}, func(params *Params) bool {
806+
called = true
807+
if params.Length > 0 {
808+
gotParam = params.Params[0]
809+
}
810+
return true
811+
})
812+
813+
// Send CSI 18 t (getWinSizeChars — permitted)
814+
term.WriteString("\x1b[18t")
815+
if !called {
816+
t.Fatal("CSI t handler was not called despite window option being permitted")
817+
}
818+
if gotParam != 18 {
819+
t.Fatalf("got param %d, want 18", gotParam)
820+
}
821+
})
822+
823+
t.Run("only matching sub-command is allowed", func(t *testing.T) {
824+
t.Parallel()
825+
term := New(WithCols(80), WithRows(24), WithWindowOptions(WindowOptions{
826+
GetWinSizeChars: true, // permits param 18
827+
}))
828+
829+
var called bool
830+
term.RegisterCsiHandler(FunctionIdentifier{Final: 't'}, func(params *Params) bool {
831+
called = true
832+
return true
833+
})
834+
835+
// Send CSI 14 t (getWinSizePixels — NOT permitted)
836+
term.WriteString("\x1b[14t")
837+
if called {
838+
t.Fatal("CSI t handler was called for a non-permitted sub-command")
839+
}
840+
})
841+
842+
t.Run("non-t CSI handler is not gated", func(t *testing.T) {
843+
t.Parallel()
844+
term := New(WithCols(80), WithRows(24))
845+
846+
var called bool
847+
term.RegisterCsiHandler(FunctionIdentifier{Final: 'Z'}, func(params *Params) bool {
848+
called = true
849+
return true
850+
})
851+
852+
term.WriteString("\x1b[18Z")
853+
if !called {
854+
t.Fatal("non-t CSI handler should not be gated by window options")
855+
}
856+
})
857+
858+
t.Run("CSI t with prefix is not gated", func(t *testing.T) {
859+
t.Parallel()
860+
term := New(WithCols(80), WithRows(24))
861+
862+
var called bool
863+
term.RegisterCsiHandler(FunctionIdentifier{Prefix: '>', Final: 't'}, func(params *Params) bool {
864+
called = true
865+
return true
866+
})
867+
868+
// Send CSI > 18 t
869+
term.WriteString("\x1b[>18t")
870+
if !called {
871+
t.Fatal("CSI > t handler should not be gated by window options")
872+
}
873+
})
874+
}
875+
776876
func TestTerminalRegisterEscHandler(t *testing.T) {
777877
t.Parallel()
778878
term := newTestTerminal(80, 24)

0 commit comments

Comments
 (0)