Skip to content

Commit 434bcd4

Browse files
committed
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.
1 parent 38942a7 commit 434bcd4

2 files changed

Lines changed: 71 additions & 23 deletions

File tree

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,57 @@ const TMUX_29_BSD_NO_PANE_OPTIONS =
2525

2626
/** The separator the format strings use. */
2727
const F = '<~sim~>'
28+
/** A frame as one call makes it; the real one is random per call. */
29+
const FRAME = '<~0123456789abcdef~>'
30+
/** One record as tmux prints it for a framed format. */
31+
const framed = (record: string) => `${FRAME}${record}${FRAME}`
32+
33+
/**
34+
* Real `list-panes -F` output, framed with {@link FRAME}, for a pane whose working directory is
35+
* `…/a` + newline + `user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home`: its own record breaks in two,
36+
* and the second half reads as a whole pane of its own. Captured verbatim from each binary.
37+
*/
38+
const FORGED_ROW = {
39+
'tmux 2.9a':
40+
'<~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',
41+
'tmux 3.4':
42+
'<~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',
43+
}
2844

2945
describe('parseFormatLines', () => {
3046
it('drops lines with the wrong field count rather than mis-assigning them', () => {
31-
expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']])
47+
expect(parseFormatLines(`${framed(`a${F}b`)}\n${framed('only-one')}\n`, 2, FRAME)).toEqual([
48+
['a', 'b'],
49+
])
3250
})
3351

3452
it('reads a field that ends with part of the separator as it is', () => {
3553
// A cwd or window name may end with any text, including all but the separator's last character.
3654
const partial = F.slice(0, -1)
37-
expect(parseFormatLines(`/tmp/x/p${partial}${F}1\n`, 2)).toEqual([[`/tmp/x/p${partial}`, '1']])
38-
expect(parseFormatLines(`tail${partial}${F}%3${F}zsh\n`, 3)).toEqual([
55+
expect(parseFormatLines(`${framed(`/tmp/x/p${partial}${F}1`)}\n`, 2, FRAME)).toEqual([
56+
[`/tmp/x/p${partial}`, '1'],
57+
])
58+
expect(parseFormatLines(`${framed(`tail${partial}${F}%3${F}zsh`)}\n`, 3, FRAME)).toEqual([
3959
[`tail${partial}`, '%3', 'zsh'],
4060
])
4161
})
4262

4363
it('drops a line whose field holds the whole separator rather than misread it', () => {
44-
expect(parseFormatLines(`a${F}b${F}c\n`, 2)).toEqual([])
64+
expect(parseFormatLines(`${framed(`a${F}b${F}c`)}\n`, 2, FRAME)).toEqual([])
65+
})
66+
67+
it.each(Object.entries(FORGED_ROW))(
68+
'never reads a row a directory name forges with a newline (%s)',
69+
(_version, stdout) => {
70+
expect(parseFormatLines(stdout, 5, FRAME)).toEqual([])
71+
}
72+
)
73+
74+
it('reads only records framed by this call', () => {
75+
const other = '<~fedcba9876543210~>'
76+
expect(parseFormatLines(`${other}a${F}b${other}\n${framed(`c${F}d`)}\n`, 2, FRAME)).toEqual([
77+
['c', 'd'],
78+
])
4579
})
4680
})
4781

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

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* of the shell that launched it.
1818
*/
1919
import { spawn } from 'node:child_process'
20+
import { randomBytes } from 'node:crypto'
2021
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2122
import { tmpdir } from 'node:os'
2223
import { dirname, join } from 'node:path'
@@ -41,12 +42,24 @@ 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 `-F` format for these fields whose every record starts and ends with a marker made fresh for
53+
* this one call. tmux prints a newline inside a field as is, so a directory named
54+
* `a\nuser:0.0<~sim~>…` would otherwise end one record early and forge another. Nobody outside
55+
* this call knows the marker, so no field can forge a framed record, and the halves of a record a
56+
* newline split are each unframed and dropped.
57+
*/
58+
function framedFormat(fields: string[]): { format: string; frame: string } {
59+
const frame = `<~${randomBytes(8).toString('hex')}~>`
60+
return { format: `${frame}${fields.join(FIELD)}${frame}`, frame }
61+
}
62+
5063
export interface TmuxCommandResult {
5164
ok: boolean
5265
stdout: string
@@ -129,18 +142,20 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
129142
}
130143

131144
/**
132-
* Parses `list-clients`/`list-panes` output into records.
145+
* Parses `list-clients`/`list-panes` output into records: only lines framed whole by this call's
146+
* marker, each with exactly the fields asked for.
133147
*
134148
* Split on a dedicated separator rather than whitespace: window names and
135149
* working directories contain spaces, and a path with a space would otherwise
136150
* shift every later field by one.
137151
*/
138-
export function parseFormatLines(stdout: string, fields: number): string[][] {
152+
export function parseFormatLines(stdout: string, fields: number, frame: string): string[][] {
139153
return stdout
140154
.split('\n')
141-
.map((line) => line.trimEnd())
142-
.filter((line) => line.length > 0)
143-
.map((line) => line.split(FIELD))
155+
.filter(
156+
(line) => line.length >= frame.length * 2 && line.startsWith(frame) && line.endsWith(frame)
157+
)
158+
.map((line) => line.slice(frame.length, line.length - frame.length).split(FIELD))
144159
.filter((parts) => parts.length === fields)
145160
}
146161

@@ -206,11 +221,11 @@ export async function resolveAttachment(
206221
shellPid: number,
207222
env: NodeJS.ProcessEnv
208223
): Promise<TmuxAttachment | null> {
209-
const format = ['#{client_pid}', '#{client_tty}', '#{client_session}'].join(FIELD)
224+
const { format, frame } = framedFormat(['#{client_pid}', '#{client_tty}', '#{client_session}'])
210225
const listed = await runTmux(['list-clients', '-F', format], env)
211226
if (!listed.ok) return null
212227

213-
const clients = parseFormatLines(listed.stdout, 3)
228+
const clients = parseFormatLines(listed.stdout, 3, frame)
214229
if (clients.length === 0) return null
215230

216231
const parents = await listProcessParents()
@@ -226,28 +241,27 @@ export async function resolveAttachment(
226241

227242
/** The active pane of a session, as a target usable by every other call. */
228243
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
244+
const { format, frame } = framedFormat(['#{session_name}:#{window_index}.#{pane_index}'])
245+
const result = await runTmux(['display-message', '-p', '-t', session, format], env)
246+
if (!result.ok) return null
247+
const [target] = parseFormatLines(result.stdout, 1, frame)[0] ?? []
248+
return target || null
235249
}
236250

237251
export async function listPanes(
238252
session: string,
239253
env: NodeJS.ProcessEnv
240254
): Promise<TerminalPaneState[]> {
241-
const format = [
255+
const { format, frame } = framedFormat([
242256
'#{session_name}:#{window_index}.#{pane_index}',
243257
'#{window_name}',
244258
'#{pane_current_command}',
245259
'#{pane_current_path}',
246260
'#{pane_active}',
247-
].join(FIELD)
261+
])
248262
const result = await runTmux(['list-panes', '-s', '-t', session, '-F', format], env)
249263
if (!result.ok) return []
250-
return parseFormatLines(result.stdout, 5).map(
264+
return parseFormatLines(result.stdout, 5, frame).map(
251265
([target, windowName, command, cwd, active]): TerminalPaneState => ({
252266
target,
253267
windowName,

0 commit comments

Comments
 (0)