Skip to content

Commit 566cea6

Browse files
authored
fix(desktop): frame each tmux format record so a newline in a field cannot forge a pane (#8725)
* fix(desktop): frame each tmux format record so a newline in a field cannot forge a pane tmux prints a newline inside a `-F` field as is. A pane whose working directory (or window name, or session name) held a newline followed by separator-joined text ended its own record early and forged another: any target, command and path, while the real pane disappeared from `panes`. Every `-F` read (list-clients, list-panes, the active pane) now frames each record with a marker made fresh for that call, and only lines framed whole by it are read. Nobody outside the call knows the marker, so no field can forge a record, and the halves of a record a newline split are each dropped. * fix(desktop): make the tmux format frame with the shared random helper * fix(desktop): neutralise newlines at the source in tmux fields others can set The per-call frame is not a secret: on macOS `ps` shows any process's arguments, and on Linux `/proc/<pid>/cmdline` is world-readable, so someone who owns the directory a pane sits in can read the frame and rename the directory before tmux reads it. tmux now replaces each newline with `<NL>` itself (`#{s/\n/<NL>/:…}`) in the fields someone other than the user can set: the pane's directory, its command, and the window title a program sets. A record is then one line, and the frame stays as a second line of defence. tmux output is also decoded as a stream, so a character split across two chunks arrives whole.
1 parent 334b9f3 commit 566cea6

2 files changed

Lines changed: 192 additions & 30 deletions

File tree

‎apps/desktop/src/main/terminal/tmux.test.ts‎

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
awaitRun,
88
closeRunPane,
99
isDescendantOf,
10+
listPanes,
1011
parseFormatLines,
1112
pollRun,
1213
resolveAttachment,
@@ -26,23 +27,85 @@ const TMUX_29_BSD_NO_PANE_OPTIONS =
2627

2728
/** The separator the format strings use. */
2829
const F = '<~sim~>'
30+
/** A frame as one call makes it; the real one is random per call. */
31+
const FRAME = '<~0123456789abcdef~>'
32+
/** One record as tmux prints it for a framed format. */
33+
const framed = (record: string) => `${FRAME}${record}${FRAME}`
34+
35+
/**
36+
* Real `list-panes -F` output, framed with {@link FRAME}, for a pane whose working directory is
37+
* `…/a` + newline + `user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home`: its own record breaks in two,
38+
* and the second half reads as a whole pane of its own. Captured verbatim from each binary.
39+
*/
40+
const FORGED_ROW = {
41+
'tmux 2.9a':
42+
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.oI9xEVhWLA/a\nuser:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
43+
'tmux 3.4':
44+
'<~0123456789abcdef~>user:0.0<~sim~>bash<~sim~>sleep<~sim~>/tmp/tmp.2V5Ie57hMp/a\nuser:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
45+
}
46+
47+
/**
48+
* The same pane, captured verbatim from each binary with newlines neutralised in the fields others
49+
* can set (`#{s/<newline>/<NL>/:…}`): one line, holding the would-be forged row as plain text.
50+
*/
51+
const NEUTRALISED_ROW = {
52+
'tmux 2.9a':
53+
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.CXoBFxxZAf/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
54+
'tmux 3.4':
55+
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.Wz6L8cjmce/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
56+
}
2957

3058
describe('parseFormatLines', () => {
3159
it('drops lines with the wrong field count rather than mis-assigning them', () => {
32-
expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']])
60+
expect(parseFormatLines(`${framed(`a${F}b`)}\n${framed('only-one')}\n`, 2, FRAME)).toEqual([
61+
['a', 'b'],
62+
])
3363
})
3464

3565
it('reads a field that ends with part of the separator as it is', () => {
3666
// A cwd or window name may end with any text, including all but the separator's last character.
3767
const partial = F.slice(0, -1)
38-
expect(parseFormatLines(`/tmp/x/p${partial}${F}1\n`, 2)).toEqual([[`/tmp/x/p${partial}`, '1']])
39-
expect(parseFormatLines(`tail${partial}${F}%3${F}zsh\n`, 3)).toEqual([
68+
expect(parseFormatLines(`${framed(`/tmp/x/p${partial}${F}1`)}\n`, 2, FRAME)).toEqual([
69+
[`/tmp/x/p${partial}`, '1'],
70+
])
71+
expect(parseFormatLines(`${framed(`tail${partial}${F}%3${F}zsh`)}\n`, 3, FRAME)).toEqual([
4072
[`tail${partial}`, '%3', 'zsh'],
4173
])
4274
})
4375

4476
it('drops a line whose field holds the whole separator rather than misread it', () => {
45-
expect(parseFormatLines(`a${F}b${F}c\n`, 2)).toEqual([])
77+
expect(parseFormatLines(`${framed(`a${F}b${F}c`)}\n`, 2, FRAME)).toEqual([])
78+
})
79+
80+
it.each(Object.entries(FORGED_ROW))(
81+
'never reads a row a directory name forges with a newline (%s)',
82+
(_version, stdout) => {
83+
expect(parseFormatLines(stdout, 5, FRAME)).toEqual([])
84+
}
85+
)
86+
87+
it('drops a line framed at one end only, whatever its field count', () => {
88+
const fields = `user:0.0${F}x${F}y${F}z${F}1`
89+
// As long as a frame, so a check of one end alone would cut it off and find five fields.
90+
const pad = 'J'.repeat(FRAME.length)
91+
expect(parseFormatLines(`${pad}${fields}${FRAME}\n`, 5, FRAME)).toEqual([])
92+
expect(parseFormatLines(`${FRAME}${fields}${pad}\n`, 5, FRAME)).toEqual([])
93+
})
94+
95+
it.each(Object.entries(NEUTRALISED_ROW))(
96+
'reads the forging pane as one line once newlines are neutralised (%s)',
97+
(_version, stdout) => {
98+
// One line: no forged row. The separator text in its path then drops it whole.
99+
expect(stdout.trimEnd().split('\n')).toHaveLength(1)
100+
expect(parseFormatLines(stdout, 5, FRAME)).toEqual([])
101+
}
102+
)
103+
104+
it('reads only records framed by this call', () => {
105+
const other = '<~fedcba9876543210~>'
106+
expect(parseFormatLines(`${other}a${F}b${other}\n${framed(`c${F}d`)}\n`, 2, FRAME)).toEqual([
107+
['c', 'd'],
108+
])
46109
})
47110
})
48111

@@ -140,6 +203,10 @@ interface FakeTmuxState {
140203
retagBeforeAction?: boolean
141204
/** Attached clients, as `list-clients` reports them. */
142205
clients?: Array<{ pid: string; tty: string; session: string }>
206+
/** A session's panes as `list-panes` reports them, with fields others can set. */
207+
listed?: Array<{ windowName: string; command: string; cwd: string }>
208+
/** Every `-F` format the fake was asked for, in order. */
209+
formats?: string[]
143210
/** Commands the fake holds until the file named here exists, like a busy tmux server. */
144211
hold?: Record<string, string>
145212
/** Commands the fake is holding right now. */
@@ -232,6 +299,31 @@ switch (args[0]) {
232299
process.stdout.write(value + '\\n')
233300
break
234301
}
302+
case 'list-panes': {
303+
// Formats as tmux evaluates them: \`#{s/pattern/replacement/:field}\` substitutes, and every
304+
// other field prints as is, a newline included.
305+
const format = args[args.indexOf('-F') + 1]
306+
state.formats = [...(state.formats ?? []), format]
307+
save()
308+
;(state.listed ?? []).forEach((pane, index) => {
309+
const fields = {
310+
session_name: 'work',
311+
window_index: '0',
312+
pane_index: String(index),
313+
window_name: pane.windowName,
314+
pane_current_command: pane.command,
315+
pane_current_path: pane.cwd,
316+
pane_active: index === 0 ? '1' : '0',
317+
}
318+
const line = format
319+
.replace(/#\\{s\\/([^/]*)\\/([^/]*)\\/:([a-z_]+)\\}/g, (_, from, to, name) =>
320+
fields[name].split(from).join(to)
321+
)
322+
.replace(/#\\{([a-z_]+)\\}/g, (_, name) => fields[name])
323+
process.stdout.write(line + '\\n')
324+
})
325+
break
326+
}
235327
case 'list-clients': {
236328
const format = args[args.indexOf('-F') + 1]
237329
for (const client of state.clients ?? []) {
@@ -293,6 +385,49 @@ function fakeTmux(options: { exec?: boolean } = {}) {
293385
}
294386
}
295387

388+
describe("listing a session's panes", () => {
389+
const dirs: string[] = []
390+
391+
afterEach(() => {
392+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
393+
})
394+
395+
it('lists a pane whose directory, command or title holds a newline, as one pane', async () => {
396+
const tmux = fakeTmux()
397+
dirs.push(tmux.dir)
398+
tmux.write({
399+
...tmux.read(),
400+
listed: [
401+
{ windowName: 'build\nlogs', command: 'make', cwd: '/tmp/a\nuser:0.0' },
402+
{ windowName: 'zsh', command: 'zsh', cwd: '/tmp' },
403+
],
404+
})
405+
406+
expect(await listPanes('work', tmux.env)).toEqual([
407+
{
408+
target: 'work:0.0',
409+
windowName: 'build<NL>logs',
410+
command: 'make',
411+
cwd: '/tmp/a<NL>user:0.0',
412+
active: true,
413+
},
414+
{ target: 'work:0.1', windowName: 'zsh', command: 'zsh', cwd: '/tmp', active: false },
415+
])
416+
})
417+
418+
it('frames each call with a marker of its own', async () => {
419+
const tmux = fakeTmux()
420+
dirs.push(tmux.dir)
421+
await listPanes('work', tmux.env)
422+
await listPanes('work', tmux.env)
423+
424+
const frames = (tmux.read().formats ?? []).map((format) => /^<~([0-9a-f]+)~>/.exec(format)?.[1])
425+
expect(frames).toHaveLength(2)
426+
expect(frames[0]).toMatch(/^[0-9a-f]{16}$/)
427+
expect(frames[1]).not.toBe(frames[0])
428+
})
429+
})
430+
296431
describe('finding the tmux session a shell runs', () => {
297432
const dirs: string[] = []
298433

‎apps/desktop/src/main/terminal/tmux.ts‎

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { TerminalPaneState } from '@sim/terminal-protocol'
2525
import { getErrorMessage } from '@sim/utils/errors'
2626
import { sleep } from '@sim/utils/helpers'
2727
import { generateId } from '@sim/utils/id'
28+
import { generateRandomHex } from '@sim/utils/random'
2829

2930
const logger = createLogger('DesktopTmux')
3031

@@ -41,12 +42,34 @@ const RUN_POLL_INTERVAL_MS = 250
4142
/**
4243
* Field separator for `-F` output. Printable on purpose: tmux 3.4 and 3.5 print a control
4344
* character as its octal escape, so a control-character separator arrived as the text `\037` and
44-
* no line split. No tmux escapes these characters. No proper prefix of the separator is also a
45-
* suffix of it, so it can only be found where it was written or wholly inside a field: a field
46-
* holding it changes the line's field count, and that line is dropped rather than misread.
45+
* no line split. No tmux escapes these characters. Fields are untrusted text (a directory or
46+
* window name can hold the separator, or a newline), so records are also framed per call: see
47+
* {@link framedFormat}.
4748
*/
4849
const FIELD = '<~sim~>'
4950

51+
/**
52+
* A field someone other than the user can set: a directory name (`pane_current_path`), a program's
53+
* name (`pane_current_command`), or a window title a program sets (`window_name`). tmux prints a
54+
* newline inside a field as is, and a newline would end the record early and let the rest read as
55+
* a record of its own, so tmux replaces each newline with `<NL>` before printing.
56+
*/
57+
function untrusted(field: string): string {
58+
return `#{s/\n/<NL>/:${field}}`
59+
}
60+
61+
/**
62+
* A `-F` format whose every record starts and ends with a marker made fresh for this call; only
63+
* lines framed whole by it are read, so a line that is not a whole record is dropped rather than
64+
* misread. The marker is a second line of defence, not a secret (another user can read a process's
65+
* arguments on many systems), which is why newlines are neutralised at the source too
66+
* ({@link untrusted}).
67+
*/
68+
function framedFormat(fields: string[]): { format: string; frame: string } {
69+
const frame = `<~${generateRandomHex(16)}~>`
70+
return { format: `${frame}${fields.join(FIELD)}${frame}`, frame }
71+
}
72+
5073
export interface TmuxCommandResult {
5174
ok: boolean
5275
stdout: string
@@ -112,11 +135,14 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
112135
finish({ ok: false, stdout, stderr: 'tmux did not respond' })
113136
}, TMUX_TIMEOUT_MS)
114137

115-
child.stdout?.on('data', (chunk: Buffer) => {
116-
stdout += chunk.toString()
138+
// Decoded as streams, so a character split across two chunks arrives whole.
139+
child.stdout?.setEncoding('utf8')
140+
child.stderr?.setEncoding('utf8')
141+
child.stdout?.on('data', (chunk: string) => {
142+
stdout += chunk
117143
})
118-
child.stderr?.on('data', (chunk: Buffer) => {
119-
stderr += chunk.toString()
144+
child.stderr?.on('data', (chunk: string) => {
145+
stderr += chunk
120146
})
121147
child.on('error', (error) => {
122148
if ((error as NodeJS.ErrnoException).code === 'ENOENT') tmuxBinaryMissing = true
@@ -129,18 +155,20 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
129155
}
130156

131157
/**
132-
* Parses `list-clients`/`list-panes` output into records.
158+
* Parses `list-clients`/`list-panes` output into records: only lines framed whole by this call's
159+
* marker, each with exactly the fields asked for.
133160
*
134161
* Split on a dedicated separator rather than whitespace: window names and
135162
* working directories contain spaces, and a path with a space would otherwise
136163
* shift every later field by one.
137164
*/
138-
export function parseFormatLines(stdout: string, fields: number): string[][] {
165+
export function parseFormatLines(stdout: string, fields: number, frame: string): string[][] {
139166
return stdout
140167
.split('\n')
141-
.map((line) => line.trimEnd())
142-
.filter((line) => line.length > 0)
143-
.map((line) => line.split(FIELD))
168+
.filter(
169+
(line) => line.length >= frame.length * 2 && line.startsWith(frame) && line.endsWith(frame)
170+
)
171+
.map((line) => line.slice(frame.length, line.length - frame.length).split(FIELD))
144172
.filter((parts) => parts.length === fields)
145173
}
146174

@@ -206,11 +234,11 @@ export async function resolveAttachment(
206234
shellPid: number,
207235
env: NodeJS.ProcessEnv
208236
): Promise<TmuxAttachment | null> {
209-
const format = ['#{client_pid}', '#{client_tty}', '#{client_session}'].join(FIELD)
237+
const { format, frame } = framedFormat(['#{client_pid}', '#{client_tty}', '#{client_session}'])
210238
const listed = await runTmux(['list-clients', '-F', format], env)
211239
if (!listed.ok) return null
212240

213-
const clients = parseFormatLines(listed.stdout, 3)
241+
const clients = parseFormatLines(listed.stdout, 3, frame)
214242
if (clients.length === 0) return null
215243

216244
const parents = await listProcessParents()
@@ -226,28 +254,27 @@ export async function resolveAttachment(
226254

227255
/** The active pane of a session, as a target usable by every other call. */
228256
export async function activePane(session: string, env: NodeJS.ProcessEnv): Promise<string | null> {
229-
const result = await runTmux(
230-
['display-message', '-p', '-t', session, '#{session_name}:#{window_index}.#{pane_index}'],
231-
env
232-
)
233-
const target = result.stdout.trim()
234-
return result.ok && target ? target : null
257+
const { format, frame } = framedFormat(['#{session_name}:#{window_index}.#{pane_index}'])
258+
const result = await runTmux(['display-message', '-p', '-t', session, format], env)
259+
if (!result.ok) return null
260+
const [target] = parseFormatLines(result.stdout, 1, frame)[0] ?? []
261+
return target || null
235262
}
236263

237264
export async function listPanes(
238265
session: string,
239266
env: NodeJS.ProcessEnv
240267
): Promise<TerminalPaneState[]> {
241-
const format = [
268+
const { format, frame } = framedFormat([
242269
'#{session_name}:#{window_index}.#{pane_index}',
243-
'#{window_name}',
244-
'#{pane_current_command}',
245-
'#{pane_current_path}',
270+
untrusted('window_name'),
271+
untrusted('pane_current_command'),
272+
untrusted('pane_current_path'),
246273
'#{pane_active}',
247-
].join(FIELD)
274+
])
248275
const result = await runTmux(['list-panes', '-s', '-t', session, '-F', format], env)
249276
if (!result.ok) return []
250-
return parseFormatLines(result.stdout, 5).map(
277+
return parseFormatLines(result.stdout, 5, frame).map(
251278
([target, windowName, command, cwd, active]): TerminalPaneState => ({
252279
target,
253280
windowName,

0 commit comments

Comments
 (0)