Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 29 additions & 1 deletion apps/temps-cli/src/commands/data/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('validateFilter', () => {
// envelope. Sending it would let the server drop the filter and return a
// full unfiltered table, which reads as a correct answer.
expect(() => validateFilter("plan = 'pro'", 'my-db')).toThrow(
/--filter must be JSON/,
/--filter must be valid JSON/,
)
})

Expand All @@ -30,6 +30,24 @@ describe('validateFilter', () => {
test('echoes what was received so a typo is visible', () => {
expect(() => validateFilter('{oops}', 'my-db')).toThrow(/\{oops\}/)
})

test('does not expose parser or terminal controls for a malicious filter', () => {
const payload =
'{"where":"\x1b]52;c;dG9rX2xpdmVfc2VjcmV0\x07\x1b[31m\n\u202E"'
let message = ''
try {
validateFilter(payload, 'service\x1b]8;;https://attacker.example\x1b\\')
} catch (error) {
message = (error as Error).message
}

expect(message).toContain('--filter must be valid JSON')
expect(message).not.toContain('\x1b')
expect(message).not.toContain('\x07')
expect(message).not.toContain('\n')
expect(message).not.toContain('\u202E')
expect(message).not.toContain('Unexpected')
})
})

describe('cell', () => {
Expand Down Expand Up @@ -58,6 +76,16 @@ describe('cell', () => {
expect(cell(0)).toBe('0')
expect(cell(false)).toBe('false')
})

test('strips terminal control sequences before truncating', () => {
const malicious =
'\x1b[31mred\x1b[0m\x1b]8;;https://attacker.example\x1b\\link\x1b]8;;\x1b\\' +
'\x1b]52;c;dG9rX2xpdmVfc2VjcmV0\x07\nforged'
const out = cell(malicious, 80)
expect(out).toBe('redlink forged')
expect(out).not.toContain('\x1b')
expect(out).not.toContain('\n')
})
})

describe('formatBytes', () => {
Expand Down
71 changes: 46 additions & 25 deletions apps/temps-cli/src/commands/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from '../../api/types.gen.js'
import { withSpinner } from '../../ui/spinner.js'
import { printTable, type TableColumn } from '../../ui/table.js'
import { sanitizeTerminalText } from '../../ui/terminal.js'
import {
newline,
header,
Expand Down Expand Up @@ -132,9 +133,9 @@ async function resolveService(
)

if (!match) {
const available = services.map((s) => s.name).join(', ')
const available = services.map((s) => sanitizeTerminalText(s.name)).join(', ')
throw new Error(
`Service "${nameOrId}" not found. Available: ${available || '(none)'}`,
`Service "${sanitizeTerminalText(nameOrId)}" not found. Available: ${available || '(none)'}`,
)
}
return { id: match.id, name: match.name, service_type: match.service_type }
Expand All @@ -149,7 +150,8 @@ async function resolveService(
*/
export function cell(value: unknown, maxLength = 40): string {
if (value === null || value === undefined) return colors.dim('null')
const text = typeof value === 'string' ? value : JSON.stringify(value)
const raw = typeof value === 'string' ? value : JSON.stringify(value)
const text = sanitizeTerminalText(raw)
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text
}

Expand Down Expand Up @@ -177,11 +179,10 @@ export function validateFilter(raw: string | undefined, service: string): string
try {
JSON.parse(raw)
return raw
} catch (e) {
} catch {
throw new Error(
`--filter must be JSON, but failed to parse: ${(e as Error).message}\n` +
` Received: ${raw}\n` +
` Run "temps data info ${service}" to see this backend's filter schema.`,
`--filter must be valid JSON. Received: ${sanitizeTerminalText(raw)}. ` +
`Run "temps data info ${sanitizeTerminalText(service)}" to see this backend's filter schema.`,
)
}
}
Expand Down Expand Up @@ -214,7 +215,9 @@ async function infoCmd(
}

newline()
header(`${icons.info} ${service.name} (${service.service_type})`)
header(
`${icons.info} ${sanitizeTerminalText(service.name)} (${sanitizeTerminalText(service.service_type)})`,
)
newline()

if (!support?.supported) {
Expand All @@ -235,7 +238,7 @@ async function infoCmd(
? 'holds containers'
: 'leaf'
console.log(
` ${colors.muted(`level ${level.level}`)} ${colors.bold(level.container_type)} ${colors.dim(`— ${holds}`)}`,
` ${colors.muted(`level ${level.level}`)} ${colors.bold(sanitizeTerminalText(level.container_type))} ${colors.dim(`— ${holds}`)}`,
)
}
newline()
Expand All @@ -246,7 +249,7 @@ async function infoCmd(
newline()
}

info(`Next: temps data containers ${service.name}`)
info(`Next: temps data containers ${sanitizeTerminalText(service.name)}`)
newline()
}

Expand Down Expand Up @@ -283,9 +286,11 @@ async function containersCmd(
return
}

const scope = options.path ? ` under ${options.path}` : ''
const scope = options.path ? ` under ${sanitizeTerminalText(options.path)}` : ''
newline()
header(`${icons.folder} Containers in ${service.name}${scope} (${rows.length})`)
header(
`${icons.folder} Containers in ${sanitizeTerminalText(service.name)}${scope} (${rows.length})`,
)

if (rows.length === 0) {
info('No containers found.')
Expand All @@ -312,11 +317,14 @@ async function containersCmd(
// common thing to get wrong, so show a real one rather than a placeholder.
const first = rows[0]
if (first) {
const nextPath = options.path ? `${options.path}/${first.name}` : first.name
const nextPath = sanitizeTerminalText(
options.path ? `${options.path}/${first.name}` : first.name,
)
const safeServiceName = sanitizeTerminalText(service.name)
if (first.can_contain_entities) {
info(`Next: temps data tables ${service.name} --path ${nextPath}`)
info(`Next: temps data tables ${safeServiceName} --path ${nextPath}`)
} else if (first.can_contain_containers) {
info(`Next: temps data containers ${service.name} --path ${nextPath}`)
info(`Next: temps data containers ${safeServiceName} --path ${nextPath}`)
}
}
newline()
Expand Down Expand Up @@ -349,10 +357,15 @@ async function tablesCmd(
}

newline()
header(`${icons.folder} ${options.path} in ${service.name} (${entities.length})`)
header(
`${icons.folder} ${sanitizeTerminalText(options.path)} in ${sanitizeTerminalText(service.name)} (${entities.length})`,
)

if (entities.length === 0) {
info('No entities found. Check the path with: temps data containers ' + service.name)
info(
'No entities found. Check the path with: temps data containers ' +
sanitizeTerminalText(service.name),
)
newline()
return
}
Expand All @@ -373,7 +386,9 @@ async function tablesCmd(
}
const firstEntity = entities[0]
if (firstEntity) {
info(`Next: temps data rows ${service.name} ${firstEntity.name} --path ${options.path}`)
info(
`Next: temps data rows ${sanitizeTerminalText(service.name)} ${sanitizeTerminalText(firstEntity.name)} --path ${sanitizeTerminalText(options.path)}`,
)
}
newline()
}
Expand Down Expand Up @@ -403,7 +418,9 @@ async function schemaCmd(
}

newline()
header(`${icons.info} ${options.path}/${entity} (${info_?.entity_type ?? 'entity'})`)
header(
`${icons.info} ${sanitizeTerminalText(options.path)}/${sanitizeTerminalText(entity)} (${sanitizeTerminalText(info_?.entity_type ?? 'entity')})`,
)
newline()
if (info_?.row_count !== null && info_?.row_count !== undefined) {
keyValue('Rows', String(info_.row_count))
Expand Down Expand Up @@ -474,7 +491,7 @@ async function rowsCmd(

newline()
header(
`${icons.info} ${options.path}/${entity} — ${result?.returned_count ?? rows.length} of ${result?.total_count ?? '?'} rows`,
`${icons.info} ${sanitizeTerminalText(options.path)}/${sanitizeTerminalText(entity)} — ${result?.returned_count ?? rows.length} of ${result?.total_count ?? '?'} rows`,
)

if (rows.length === 0) {
Expand Down Expand Up @@ -543,7 +560,7 @@ async function aiAccessCmd(
}

newline()
header(`${icons.info} AI data access — ${service.name}`)
header(`${icons.info} AI data access — ${sanitizeTerminalText(service.name)}`)
newline()
keyValue(
'Built-in assistant may read rows',
Expand All @@ -552,8 +569,8 @@ async function aiAccessCmd(
newline()
info(
current?.enabled
? `Disable with: temps data ai-access ${service.name} --disable`
: `Enable with: temps data ai-access ${service.name} --enable`,
? `Disable with: temps data ai-access ${sanitizeTerminalText(service.name)} --disable`
: `Enable with: temps data ai-access ${sanitizeTerminalText(service.name)} --enable`,
)
info(
colors.dim(
Expand Down Expand Up @@ -587,14 +604,18 @@ async function aiAccessCmd(

newline()
if (enabled) {
success(`The built-in AI assistant can now read rows from ${service.name}.`)
success(
`The built-in AI assistant can now read rows from ${sanitizeTerminalText(service.name)}.`,
)
newline()
warning(
'Rows are sent to your configured AI provider. If this service stores password',
)
warning('hashes, tokens or personal data, that data leaves your infrastructure.')
} else {
success(`The built-in AI assistant can no longer read rows from ${service.name}.`)
success(
`The built-in AI assistant can no longer read rows from ${sanitizeTerminalText(service.name)}.`,
)
info('Table and column names remain readable.')
}
newline()
Expand Down
17 changes: 17 additions & 0 deletions apps/temps-cli/src/ui/table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { createTable } from './table.js'

describe('createTable', () => {
test('sanitizes headers and values before terminal rendering', () => {
const rendered = createTable(
[{ name: '\x1b]52;c;dG9rX2xpdmVfc2VjcmV0\x07safe\nrow' }],
[{ header: '\x1b[31mName\x1b[0m', key: 'name' }],
{ style: 'minimal' },
)

expect(rendered).toContain('Name')
expect(rendered).toContain('safe row')
expect(rendered).not.toContain(']52;')
expect(rendered).not.toContain('\x1b[31m')
})
})
18 changes: 12 additions & 6 deletions apps/temps-cli/src/ui/table.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Table from 'cli-table3'
import chalk from 'chalk'
import { colors } from './output.js'
import { sanitizeTerminalText } from './terminal.js'

export interface TableColumn<T> {
header: string
Expand Down Expand Up @@ -121,7 +122,7 @@ export function createTable<T>(
const preset = stylePresets[options.style ?? 'default']

const table = new Table({
head: columns.map((col) => colors.bold(col.header)),
head: columns.map((col) => colors.bold(sanitizeTerminalText(col.header))),
colAligns: columns.map((col) => col.align ?? 'left'),
colWidths: columns.map((col) => col.width ?? null),
...preset,
Expand All @@ -139,7 +140,8 @@ export function createTable<T>(
value = ''
}

let strValue = value === null || value === undefined ? '' : String(value)
let strValue =
value === null || value === undefined ? '' : sanitizeTerminalText(value)

if (col.color) {
strValue = col.color(strValue, item)
Expand Down Expand Up @@ -177,8 +179,11 @@ export function detailsTable(
})

for (const [key, value] of Object.entries(details)) {
const displayValue = value === null || value === undefined ? colors.muted('not set') : String(value)
table.push([colors.muted(key), displayValue])
const displayValue =
value === null || value === undefined
? colors.muted('not set')
: sanitizeTerminalText(value)
table.push([colors.muted(sanitizeTerminalText(key)), displayValue])
}

console.log(table.toString())
Expand All @@ -188,6 +193,7 @@ export function detailsTable(
* Status badge formatter
*/
export function statusBadge(status: string): string {
const safeStatus = sanitizeTerminalText(status)
const statusColors: Record<string, (s: string) => string> = {
running: chalk.green,
active: chalk.green,
Expand All @@ -210,6 +216,6 @@ export function statusBadge(status: string): string {
cancelled: chalk.red,
}

const colorFn = statusColors[status.toLowerCase()] ?? chalk.white
return colorFn(`● ${status}`)
const colorFn = statusColors[safeStatus.toLowerCase()] ?? chalk.white
return colorFn(`● ${safeStatus}`)
}
26 changes: 26 additions & 0 deletions apps/temps-cli/src/ui/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeTerminalText } from './terminal.js'

describe('sanitizeTerminalText', () => {
test('strips CSI colour and cursor sequences', () => {
expect(sanitizeTerminalText('\x1b[31mforged\x1b[0m')).toBe('forged')
expect(sanitizeTerminalText('before\x1b[2Jafter')).toBe('beforeafter')
})

test('strips OSC 8 links and OSC 52 clipboard writes', () => {
const link = '\x1b]8;;https://attacker.example\x1b\\click\x1b]8;;\x1b\\'
const clipboard = '\x1b]52;c;dG9rX2xpdmVfc2VjcmV0\x07visible'
expect(sanitizeTerminalText(link)).toBe('click')
expect(sanitizeTerminalText(clipboard)).toBe('visible')
})

test('collapses newlines and strips C0, C1, and bidi overrides', () => {
expect(
sanitizeTerminalText(
'one\r\ntwo\tthree\x00\x9b31m\u061C\u200E\u200F\u202E',
),
).toBe(
'one two three',
)
})
})
Loading
Loading