morphit/apps/matrix-bot/scripts/json-str-injection-smoke.ts

235 lines
8.4 KiB
TypeScript

#!/usr/bin/env tsx
/**
* json-str-injection-smoke — regression test for AUDIT-1 (cp17
* deep-deep): json_str() in ops/scripts/lib/emit.sh must encode
* all C0 control characters so an attacker can't forge journal
* entries via newline injection through untrusted-input paths.
*
* Attack scenario (the bug this smoke prevents from regressing):
*
* 1. Unprivileged user spawns process with `comm` name
* containing literal newline + crafted JSON, e.g.
* `exec -a $'name\n{"level":"error","module":"...","event":
* "all_clear","context":{}}'`.
* 2. User triggers OOM-kill of that process.
* 3. Kernel writes OOM message to dmesg with `comm` verbatim.
* 4. dmesg-monitor reads the line, passes through json_str(),
* embeds in payload, pipes through systemd-cat.
* 5. systemd-cat creates ONE journal entry per stdin line.
* Embedded \n splits one logical emit into two journal
* lines — the second being attacker-controlled JSON.
* 6. matrix-bot reads the second line and routes it as a
* legitimate alert.
*
* The fix encodes \b\t\n\f\r as their short JSON escapes and
* all other 0x00-0x1F as \uXXXX. This smoke feeds each known
* dangerous input through json_str() (via a shell subprocess so
* we exercise the actual sed pipeline), then validates the
* output is parseable JSON whose decoded value matches the
* original input byte-for-byte.
*
* This smoke also serves as the test fixture for the original
* attack: it includes the actual attacker-style payload from
* the audit report.
*/
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const SELF_DIR = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = join(SELF_DIR, '..', '..', '..');
const EMIT_LIB = join(REPO_ROOT, 'ops', 'scripts', 'lib', 'emit.sh');
/**
* Invoke json_str via a shell subprocess. We source emit.sh and
* call the function with the given input on stdin so we exercise
* the actual sed pipeline that ships to production.
*
* The input is passed via stdin (not as an arg) to avoid any
* shell-quoting ambiguity: arbitrary bytes including newlines
* and NULs go in raw. Inside the script, we slurp stdin into a
* variable and pass to json_str.
*/
function callJsonStr(input: Buffer | string): string {
const inputBuf = typeof input === 'string' ? Buffer.from(input) : input;
// Use base64 to round-trip bytes through the shell without
// any encoding mishap. The shell script base64-decodes
// stdin, then calls json_str on the result.
const b64 = inputBuf.toString('base64');
const script = `
. "${EMIT_LIB}"
raw=$(printf '%s' '${b64}' | base64 -d; printf x)
raw=\${raw%x}
json_str "$raw"
`;
const r = spawnSync('sh', ['-c', script], { encoding: 'buffer' });
if (r.status !== 0) {
throw new Error(
`json_str subprocess exited ${r.status}: ${r.stderr.toString()}`
);
}
// sed appends a trailing newline; strip exactly one.
let out = r.stdout;
if (out.length > 0 && out[out.length - 1] === 0x0a) {
out = out.subarray(0, out.length - 1);
}
return out.toString('utf-8');
}
interface Scenario {
readonly name: string;
readonly input: Buffer | string;
/** A description of WHY this input is dangerous, surfaced
* in failure messages. */
readonly threat: string;
/** If set, expect the encoder to emit this decoded value
* rather than the original input. Used for cases where the
* bash variable boundary trims bytes before json_str() sees
* them (NUL truncation) — documenting actual behavior rather
* than asserting an impossible round-trip. */
readonly expectedEncoded?: string;
}
const scenarios: Scenario[] = [
{
name: 'plain ASCII (control case)',
input: 'hello world',
threat: 'no threat; baseline that encoding leaves clean input alone'
},
{
name: 'backslash + double-quote (original json_str scope)',
input: 'path\\with"quotes',
threat: 'these were the only chars the original implementation escaped'
},
{
name: 'embedded newline (THE primary attack vector — AUDIT-1)',
input:
'evil-proc\n{"ts":"2026-01-01T00:00:00Z","level":"error",' +
'"module":"host-resource","event":"disk_critical",' +
'"context":{"forged":true}}',
threat:
'unprivileged user injects forged JSON via process comm name → ' +
'kernel OOM logs → dmesg → sidecar → systemd-cat splits at \\n → ' +
'fake alert delivered to operator'
},
{
name: 'embedded tab',
input: 'col1\tcol2\tcol3',
threat:
'tabs are valid in dmesg output but break JSON strings if unencoded'
},
{
name: 'embedded carriage-return',
input: 'lf-platform\r\nsecond-line',
threat: 'CRLF from Windows-ish source breaks JSON same as LF'
},
{
name: 'NUL byte (bash variable semantics strip it; documented)',
input: Buffer.from([0x66, 0x6f, 0x6f, 0x00, 0x62, 0x61, 0x72]),
// bash command substitution drops the NUL byte but
// PRESERVES the surrounding bytes (bash 4.4+). Tests show
// `foo\0bar` becomes `foobar` post-substitution. The
// security-relevant property is the other 31 control chars
// (covered by the next scenario): no JSON injection is
// possible regardless of NUL handling because the actual
// attack vector — newline — is properly encoded.
expectedEncoded: 'foobar',
threat:
'NUL bytes are scrubbed at the bash-variable boundary, not by ' +
'the encoder; documented in emit.sh'
},
{
name: 'all C0 control chars (0x01-0x1F; NUL stripped at bash boundary)',
// Skip 0x00; bash variable assignment drops it. Cover all
// other 31 control characters in one byte-sequence.
input: Buffer.from(Array.from({ length: 31 }, (_, i) => i + 1)),
threat:
'every byte 0x01-0x1F must be escaped; exhaustive coverage check'
},
{
name: 'ESC char (terminal-control escape)',
input: 'normal\x1b[31mRED\x1b[0m more',
threat:
'ANSI escape sequences from process names could enable terminal-injection ' +
'when an operator views the journal with `journalctl` directly'
},
{
name: 'backslash-then-quote-then-newline (escape-sequence boundary)',
input: 'tricky\\"\nrest',
threat:
'tests that order of operations (backslash FIRST, then others) is correct — ' +
'wrong order would double-escape the literal backslash'
},
{
name: 'UTF-8 multibyte (must pass through)',
input: '日本語 Ñoño 🚀',
threat:
'no threat; multibyte UTF-8 must pass through unchanged since JSON allows it'
},
{
name: 'invalid UTF-8 byte sequences',
input: Buffer.from([0x66, 0xff, 0xfe, 0x6f]),
threat:
'invalid UTF-8 in raw kernel data shouldn\'t crash the encoder — ' +
'LC_ALL=C in the sed pipeline operates byte-wise'
}
];
console.log(`json-str-injection smoke: ${scenarios.length} scenarios\n`);
let failed = 0;
for (const s of scenarios) {
const inputBuf =
typeof s.input === 'string' ? Buffer.from(s.input) : s.input;
let encoded: string;
try {
encoded = callJsonStr(s.input);
} catch (err) {
console.log(`${s.name}: json_str subprocess FAILED: ${err}`);
console.log(` threat: ${s.threat}`);
failed++;
continue;
}
// Test 1: the encoded result, wrapped in double quotes, must
// be valid JSON.
const asJsonString = `"${encoded}"`;
let decoded: string;
try {
decoded = JSON.parse(asJsonString);
} catch (err) {
console.log(`${s.name}: encoded output is NOT valid JSON: ${err}`);
console.log(` encoded: ${JSON.stringify(encoded)}`);
console.log(` threat: ${s.threat}`);
failed++;
continue;
}
// Test 2: the decoded string must equal the input byte-for-byte
// (or the documented expectedEncoded if the scenario overrides
// it, e.g. for bash-variable-boundary NUL truncation).
const expected = s.expectedEncoded ?? inputBuf.toString('utf-8');
const decodedBuf = Buffer.from(decoded, 'utf-8');
const expectedBuf = Buffer.from(expected, 'utf-8');
if (decodedBuf.equals(expectedBuf)) {
console.log(`${s.name}: round-trips correctly`);
} else {
console.log(
`${s.name}: round-trip mismatch (encoder mangled the input)`
);
console.log(` input bytes: ${inputBuf.toString('hex')}`);
console.log(` expected bytes: ${expectedBuf.toString('hex')}`);
console.log(` got bytes: ${decodedBuf.toString('hex')}`);
console.log(` threat: ${s.threat}`);
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${scenarios.length} json-str-injection checks hold`);
process.exit(0);
}
console.error(`${failed} failed, ${scenarios.length - failed} passed`);
process.exit(1);