test(security): add failing test for HIGH-1 prototype pollution

Three PoC fixtures (tool name, bash command, model name) reproduce
the audit's HIGH-1 attack. Tests assert Object.prototype.calls stays
undefined after parsing. They fail against current parser.ts -- Task 3
will close the pollution sink with Object.create(null).
This commit is contained in:
Ninym 2026-04-17 07:29:37 +02:00
parent f2d1753d3a
commit e890d9bfc3
4 changed files with 80 additions and 0 deletions

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1,77 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { mkdtemp, mkdir, cp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { parseAllSessions } from '../../src/parser.js'
import type { DateRange } from '../../src/types.js'
// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay
// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too.
const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed)
const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000
const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000
const PROJECT_NAME = 'codeburn-poc-testing'
function makeRange(offsetMs: number): DateRange {
return {
start: new Date(RANGE_BEFORE_MS + offsetMs),
end: new Date(RANGE_AFTER_MS + offsetMs),
}
}
// Hermeticity note: the Claude provider also scans a fixed Desktop sessions
// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes
// any real sessions in practice, but these tests are not strictly isolated
// on a machine with April 2026 Claude Desktop activity. A stricter fix
// belongs in a follow-up to discoverSessions itself.
describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => {
const tmpDirs: string[] = []
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
})
afterEach(async () => {
delete (Object.prototype as Record<string, unknown>).calls
if (originalConfigDir === undefined) {
delete process.env['CLAUDE_CONFIG_DIR']
} else {
process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
}
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupPoc(fixture: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', PROJECT_NAME)
await mkdir(projectDir, { recursive: true })
await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl'))
process.env['CLAUDE_CONFIG_DIR'] = base
return base
}
it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => {
await setupPoc('proto-tool.jsonl')
await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => {
await setupPoc('proto-bash.jsonl')
await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when model name is "__proto__"', async () => {
await setupPoc('proto-model.jsonl')
await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
})