diff --git a/e2e/tests/host/persona-concurrency-mix.spec.ts b/e2e/tests/host/persona-concurrency-mix.spec.ts new file mode 100644 index 0000000..d07c721 --- /dev/null +++ b/e2e/tests/host/persona-concurrency-mix.spec.ts @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 Bryan Joshua Pedini + +/** + * PLAN §12 "at least one Playwright test must deliberately mix concurrent authenticated requests, + * invalid authentication attempts, token creation/deletion, database reads, and Docker container + * lifecycle operations, then verify the final database and API state against the operations + * actually performed". + * + * Everything below runs at the same time against one server on the real Docker Engine: + * - steady request loops (Playwright + curl) for long-lived tokens; + * - request loops for "doomed" tokens that get deleted mid-flight; + * - an invalid-authentication loop (random, near-match, revoked, malformed); + * - a CLI creator (new tokens are used immediately) and a CLI deleter; + * - a DB reader taking consistent snapshots (and `token list`, `audit`) while everyone writes; + * - real container lifecycle operations (run / stop / remove / start) on test-owned containers. + * + * Exact invariant for requests racing a deletion (justified by the code: authenticate() reads the + * token row, then RecordOperation inserts the audit row, with no common transaction): a request + * may be answered 200 only if it started before `token delete` returned; it is audited iff it was + * answered 200; an audit row may carry a time after deleted_at only for such a racing request, and + * never after the end of the token's last 200 response. + */ +import { + test, + expect, + assertDbInvariants, + expectContainersResponse, + expectErrorResponse, + msToNanos, + parseAuditOutput, + parseTokenList, + pollUntil, + readOperations, + readToken, + rfc3339ToNanos, + tokenId, + type TestContainer, +} from '../../harness'; +import { StateModel, TOKEN_CHARS, authOf, consistentSnapshot, nearMatches, snapshotProblems } from '../../harness/personas'; + +interface ApiContainer { + Id: string; + State: string; + Labels: Record; +} + +interface Sample { + token: string; + start: number; + end: number; + endWall: number; + status: number; + own?: string[]; +} + +test('concurrency mix: authenticated + invalid requests, token creation/deletion, DB reads and real container lifecycle at once — final DB and API state equal the operations performed', async ({ cli, ws, startServer, http, docker, rng }) => { + test.setTimeout(180_000); + const model = new StateModel(); + const steady = await Promise.all(Array.from({ length: 4 }, (_, i) => cli.tokenNew(`steady ${i}`))); + steady.forEach((t, i) => model.add(t, `steady ${i}`)); + const doomed = await Promise.all(Array.from({ length: 4 }, (_, i) => cli.tokenNew(`doomed ${i}`))); + doomed.forEach((t, i) => model.add(t, `doomed ${i}`)); + const revokedEarly = model.add(await cli.tokenNew('revoked early'), 'revoked early').token; + await cli.tokenDelete(revokedEarly); + model.deactivate(revokedEarly); + + const everCreated = new Map(); + for (const c of await docker.runMany(4, (i) => ({ suffix: `init${i}`, labels: { 'mix.phase': 'initial' } }), 4)) everCreated.set(c.id, c); + const server = await startServer({ docker: 'real', logLevel: 'INFO' }); + + const samples: Sample[] = []; + const invalid: number[] = []; + const deletedAt = new Map(); + const createdTokens: string[] = []; + let snapshots = 0; + let auditRows = 0; + let stopping = false; + + const request = async (token: string, viaCurl: boolean): Promise => { + const start = performance.now(); + const r = viaCurl ? await http.curl({ url: server.url(), token }) : await http.get(server.url(), { token }); + const end = performance.now(); + const s: Sample = { token, start, end, endWall: Date.now(), status: r.status }; + if (r.status === 200) { + const list = expectContainersResponse(r); + expect(list.every((c) => c.State === 'running'), 'only running containers, ever').toBe(true); + s.own = list.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((c) => c.Id); + } else expectErrorResponse(r, 401); + samples.push(s); + }; + + const steadyLoop = async (t: string, k: number) => { + for (let n = 0; !stopping; n++) await request(t, (n + k) % 5 === 0); + }; + const doomedLoop = async (t: string) => { + let after = 0; + for (let n = 0; !stopping; n++) { + const start = performance.now(); + await request(t, n % 4 === 0); + if (deletedAt.has(t) && start > deletedAt.get(t)! && ++after >= 4) return; + } + }; + const invalidLoop = async () => { + while (!stopping) { + const base = rng.pick(model.all()); + const near = nearMatches(base, rng, 1)[0]?.value ?? 'x'; + const header = rng.pick([`Bearer ${rng.string(43, TOKEN_CHARS)}`, `Bearer ${near}`, `Bearer ${revokedEarly}`, `Basic ${base}`, 'Bearer ']); + // Playwright carries printable ASCII only; skip values the oracle says would authenticate (OWS trimming) + if (!/^[\x20-\x7e]*$/.test(header) || authOf([header], (t) => model.lookup(t)).cls === 'ok') continue; + const r = await http.get(server.url(), { headers: { Authorization: header } }); + expectErrorResponse(r, 401); + invalid.push(r.status); + } + }; + + const creator = async () => { + for (let i = 0; i < 6 && !stopping; i++) { + const t = await cli.tokenNew(`created mid-flight ${i}`); + model.add(t, `created mid-flight ${i}`); + createdTokens.push(t); + for (let n = 0; n < 5; n++) await request(t, n === 4); + } + }; + const deleter = async () => { + for (const t of rng.shuffle(doomed)) { + const warm = rng.int(5, 25); + await pollUntil('traffic for the doomed token', () => (stopping || samples.filter((s) => s.token === t).length >= warm ? true : undefined), { timeoutMs: 30_000 }); + if (stopping) return; + await cli.tokenDelete(t); + deletedAt.set(t, performance.now()); + model.deactivate(t); + } + }; + const reader = async () => { + while (!stopping) { + const snap = consistentSnapshot(ws.dbPath); + expect(snapshotProblems(snap), 'consistent snapshot taken mid-flight is structurally valid').toEqual([]); + // alternately `token list` (active only: no inactive row may slip through) and `token list all` + const all = snapshots % 2 === 1; + const list = await cli.run(all ? ['token', 'list', 'all'] : ['token', 'list']); + expect(list.exitCode).toBe(0); + const rows = parseTokenList(list.stdout); + if (!all) expect(rows.filter((r) => !r.active || r.deletedAt !== null).map((r) => tokenId(r.token)), 'token list prints active tokens only').toEqual([]); + const known = new Set(model.all()); + // a token being created right now is in the database before `token new` has returned + expect(rows.every((r) => known.has(r.token) || /^created mid-flight \d$/.test(r.description)), 'no token appears that was not created by the test').toBe(true); + // `audit` while everyone writes: a complete chronological table, append-only, only known tokens + const audit = await cli.run(['audit', '1000000']); + expect(audit.exitCode).toBe(0); + const arows = parseAuditOutput(audit.stdout); + const times = arows.map((r) => rfc3339ToNanos(r.time)); + expect(times.every((t, k) => k === 0 || times[k - 1] <= t), 'audit output is chronological').toBe(true); + expect(arows.length, 'audit history only grows').toBeGreaterThanOrEqual(auditRows); + auditRows = arows.length; + const users = new Set(model.all()); + expect(arows.every((r) => users.has(r.token) && r.operation === 'GET containers'), 'audit rows belong to tokens the test created').toBe(true); + snapshots++; + } + }; + const lifecycle = async () => { + const extra = await docker.runMany(4, (i) => ({ suffix: `mid${i}`, command: i % 2 ? ['tail', '-f', '/dev/null'] : ['sleep', '3600'], labels: { 'mix.phase': 'mid', 'mix.i': String(i) } }), 2); + for (const c of extra) everCreated.set(c.id, c); + const all = [...everCreated.values()]; + await Promise.all(all.slice(0, 2).map((c) => docker.stop(c, 1))); + await Promise.all(all.slice(2, 4).map((c) => docker.remove(c))); + await docker.start(all[0]); + for (const c of await docker.runMany(2, (i) => ({ suffix: `late${i}`, labels: { 'mix.phase': 'late' } }), 2)) everCreated.set(c.id, c); + }; + + // any failure stops every loop (a failed worker must not turn into a hang) + const guard = (p: Promise) => p.catch((e) => { + stopping = true; + throw e; + }); + const orchestration = (async () => { + const work = await Promise.allSettled([creator(), deleter(), lifecycle()].map(guard)); + const bad = work.find((x) => x.status === 'rejected'); + if (bad) throw (bad as PromiseRejectedResult).reason; + await pollUntil('doomed loops finished', () => (doomed.every((t) => samples.filter((s) => s.token === t && s.start > deletedAt.get(t)!).length >= 8) ? true : undefined), { timeoutMs: 30_000 }); + stopping = true; + })(); + // wait for every worker to wind down (no stray CLI/docker processes), then surface the first failure + const settled = await Promise.allSettled([guard(orchestration), ...[...steady.map((t, k) => steadyLoop(t, k)), ...doomed.flatMap((t) => [doomedLoop(t), doomedLoop(t)]), invalidLoop(), reader()].map(guard)]); + const failed = settled.find((x) => x.status === 'rejected'); + if (failed) throw (failed as PromiseRejectedResult).reason; + + // ---- final API state equals docker ps for our containers + const running = (await docker.runningIds()).sort(); + const finalList = expectContainersResponse(await http.get(server.url(), { token: steady[0] })); + expect(finalList.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((c) => c.Id).sort(), 'final API state equals docker ps').toEqual(running); + expect(running.length).toBe(7); // 4 initial + 4 mid + 2 late \u2212 2 removed \u2212 1 still stopped + samples.push({ token: steady[0], start: performance.now(), end: performance.now(), endWall: Date.now(), status: 200 }); + expect((await server.stop()).exitCode).toBe(0); + + // every container ever seen in a 200 belongs to the set this test created + for (const s of samples) for (const id of s.own ?? []) expect(everCreated.has(id), 'only containers created by this test carry its label').toBe(true); + + // ---- database equals the operations actually performed + const ops = readOperations(ws.dbPath); + for (const t of model.all()) { + const mine = samples.filter((s) => s.token === t); + const oks = mine.filter((s) => s.status === 200); + model.audit(t, oks.length); + expect(ops.filter((o) => o.token === t).length, `${tokenId(t)}: audit rows equal the 200 responses`).toBe(oks.length); + } + for (const t of [...steady, ...createdTokens]) expect(samples.filter((s) => s.token === t).every((s) => s.status === 200), `${tokenId(t)}: never refused`).toBe(true); + let racing = 0; + for (const t of doomed) { + const del = deletedAt.get(t)!; + const mine = samples.filter((s) => s.token === t); + expect(mine.filter((s) => s.start > del && s.status !== 401), `${tokenId(t)}: everything started after the deletion is 401`).toEqual([]); + expect(mine.filter((s) => s.status === 200).every((s) => s.start < del), `${tokenId(t)}: every 200 started before the deletion returned`).toBe(true); + const row = readToken(ws.dbPath, t)!; + const lastOk = Math.max(0, ...mine.filter((s) => s.status === 200).map((s) => s.endWall)); + for (const o of ops.filter((x) => x.token === t)) { + expect(rfc3339ToNanos(o.time) <= msToNanos(lastOk + 1), 'audit row precedes the end of the last successful response').toBe(true); + if (rfc3339ToNanos(o.time) > rfc3339ToNanos(row.deleted_at!)) racing++; + } + } + expect(ops.filter((o) => o.token === revokedEarly)).toEqual([]); + expect(invalid.length).toBeGreaterThan(20); + expect(snapshots).toBeGreaterThan(3); + model.verifyDb(ws.dbPath, 'final database equals the model'); + model.verifyList(await cli.tokenList(), 'final token list'); + model.verifyList(await cli.tokenListAll(), 'final token list all', 'all'); + assertDbInvariants(ws.dbPath, { tokenCount: 4 + 4 + 1 + 6, operationCount: samples.filter((s) => s.status === 200).length }); + test.info().annotations.push({ type: 'mix', description: JSON.stringify({ requests: samples.length, invalid: invalid.length, snapshots, racingAuditRows: racing, containers: everCreated.size }) }); +}); diff --git a/e2e/tests/host/persona-cross-abuse.spec.ts b/e2e/tests/host/persona-cross-abuse.spec.ts new file mode 100644 index 0000000..45483a8 --- /dev/null +++ b/e2e/tests/host/persona-cross-abuse.spec.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 Bryan Joshua Pedini + +/** + * PLAN §12 cross-persona abuse sequence: a power-user state (tokens, revocations, audit history + * across restarts, a large container set) is attacked by malicious and fuzzer traffic — HTTP + * enumeration, near-matches, oracle-checked fuzz requests, malformed requests, failing CLI + * garbage and a foreign write lock, all at the same time — then the server is killed (SIGKILL) + * in the middle of a burst, restarted on the same database, and finally administered normally. + * Recovery and integrity are verified against the model after every phase. + */ +import { + test, + expect, + expectCliFailure, + errorMessage, + expectContainersResponse, + expectErrorResponse, + mapLimit, + pollUntil, + formatAuditOutput, + readOperations, + type CliResult, + type Rng, +} from '../../harness'; +import { DatabaseSync } from 'node:sqlite'; +import { + StateModel, + TOKEN_CHARS, + authOf, + checkResponse, + cliOutcome, + configOutcome, + expectHttp, + expectNoTokenFragments, + fuzzAddr, + fuzzLogLevel, + fuzzPort, + fuzzRequest, + fuzzText, + malformedRequest, + matchesConfigError, + nearMatches, + onlyResponse, + rawBytes, + stateDigest, + AUDIT_ALL_ARGV_NEAR_MISSES, + HELP_NEAR_MISSES, + LIST_ALL_NEAR_MISSES, + expectedAuditStdout, + type AppEnv, +} from '../../harness/personas'; + +/** Failing CLI invocations only (the attacker cannot change state through the CLI without a valid token argument). */ +function garbageCli(r: Rng, model: StateModel): { args: string[]; env: AppEnv; addrValid?: boolean } { + for (;;) { + const kind = r.pick(['usage', 'usage', 'delete', 'delete', 'config']); + if (kind === 'config') { + const a = fuzzAddr(r); + const env: AppEnv = r.bool() ? { HTTP_ADDR: a.value } : r.bool() ? { HTTP_PORT: fuzzPort(r) } : { LOG_LEVEL: fuzzLogLevel(r) }; + const cfg = configOutcome(env, env.HTTP_ADDR === undefined ? undefined : a.valid); + if (!cfg.ok) return { args: ['token', 'list'], env, addrValid: env.HTTP_ADDR === undefined ? undefined : a.valid }; + continue; + } + const args = + kind === 'usage' + ? r.pick([['token'], ['tokens'], ['token', 'nwe'], ['token', 'list', fuzzText(r, { maxLen: 64 }).value], ['token', 'new', 'a', fuzzText(r, { maxLen: 64 }).value], ['token', 'delete'], [fuzzText(r, { maxLen: 64 }).value], [...r.pick(HELP_NEAR_MISSES)], [...r.pick(LIST_ALL_NEAR_MISSES)], [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES)]]) + : ['token', 'delete', r.pick([...nearMatches(r.pick(model.all()), r, 2).map((m) => m.value), r.pick(model.inactive().length ? model.inactive() : ['x']), fuzzText(r, { maxLen: 128 }).value, r.string(43, TOKEN_CHARS)])]; + const o = cliOutcome(args, model); + if (o.kind === 'error') return { args, env: {} }; + } +} + +test('power-user state attacked by malicious + fuzzer traffic, a write lock and a SIGKILL mid-burst, then normal administration: full recovery and integrity', async ({ cli, ws, startServer, http, fuzz, secrets, e2e }) => { + test.setTimeout(180_000); + const r = fuzz.rng.fork('cross'); + const model = new StateModel(); + e2e.trackedDbs.add(ws.dbPath); + + // ---- 1. power-user state + const descs = Array.from({ length: 40 }, (_, i) => (i % 5 === 0 ? fuzzText(r, { maxLen: 512 }).value : `team-${i} ${r.pick(['ci', 'ops', 'dash'])}`)); + (await mapLimit(descs, 8, (d) => cli.tokenNew(d))).forEach((t, i) => model.add(t, descs[i])); + for (const t of r.shuffle(model.all()).slice(0, 10)) { + await cli.tokenDelete(t); + model.deactivate(t); + } + const server = await startServer({ logLevel: 'DEBUG' }); + const fake = server.fakeDocker!.many(300, r.fork('containers')); + for (let round = 0; round < 3; round++) { + const users = r.shuffle(model.active()).slice(0, 15); + const plan = Array.from({ length: 100 }, (_, i) => users[i % users.length]); + await mapLimit(plan, 16, async (t) => { + expect(expectContainersResponse(await http.get(server.url(), { token: t }))).toHaveLength(300); + }); + for (const t of plan) model.audit(t); + if (round < 2) await server.restart(); + } + model.verifyDb(ws.dbPath, 'power-user state'); + const lookup = (t: string) => model.lookup(t); + + // ---- 2. attack: everything at once + await test.step('concurrent attack: enumeration, near-matches, fuzz, malformed requests, CLI garbage and a 2 s foreign write lock', async () => { + const audits: string[] = []; + const host = { host: server.connectHost, port: server.port }; + const enumeration = mapLimit(Array.from({ length: 400 }, () => r.string(43, TOKEN_CHARS)), 8, async (g) => { + expectErrorResponse(await http.get(server.url(), { token: g }), 401); + }); + const nearCases = model.all().flatMap((t) => nearMatches(t, r, 6)).filter((m) => !/[\r\n]/.test(m.value)); + const near = mapLimit(nearCases, 6, async (m) => { + const header = `Bearer ${m.value}`; + const exp = authOf([header], lookup); + const res = onlyResponse(await http.raw({ ...host, data: Buffer.from(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: ${header}\r\nConnection: close\r\n\r\n`, 'utf8'), timeoutMs: 20_000 }), m.kind); + if (exp.cls === 'ok') { + expectContainersResponse(res); + audits.push(exp.token!); + } else expectErrorResponse(res, 401); + }); + const fuzzCases = Array.from({ length: 300 }, (_, i) => (i % 8 === 0 ? malformedRequest(r, model) : fuzzRequest(r, model))); + const fuzzing = mapLimit(fuzzCases, 6, async (c, i) => { + const exp = expectHttp(c, lookup, true); + fuzz.record({ phase: 'attack', i, method: c.method, target: c.target.slice(0, 200), headers: c.headers.map(([k, v]) => [k, v.slice(0, 200)]), expected: exp }); + const res = onlyResponse(await http.raw({ ...host, data: rawBytes(c, { keepAlive: c.body !== undefined }), head: c.method === 'HEAD', timeoutMs: 20_000 }), `fuzz ${i}`); + checkResponse(res, exp, { head: c.method === 'HEAD', containers: 300, secrets, label: `fuzz ${i}` }); + if (exp.audited && res.status === 200) audits.push(exp.audited); + }); + // every 10th garbage invocation is a near miss of `audit all` (`audit ALL`, `audit all x`, ...) + const garbageCases = Array.from({ length: 60 }, (_, i) => (i % 10 === 3 ? { args: [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES.filter((a) => /^audit$/i.test(a[0])))], env: {} as AppEnv, addrValid: undefined } : garbageCli(r, model))); + const auditAllMisses = garbageCases.filter((g) => !Object.keys(g.env).length && g.args.some((a) => /[a\u0430]ll/i.test(a)) && /^audit$/i.test(g.args[0])).length; + expect(auditAllMisses, 'the garbage includes near misses of audit all').toBeGreaterThanOrEqual(6); + const garbage = mapLimit(garbageCases, 4, async (g, i) => { + fuzz.record({ phase: 'cli-garbage', i, args: g.args.map((a) => a.slice(0, 120)), env: g.env }); + const res: CliResult = await cli.run(g.args, { env: g.env }); + if (Object.keys(g.env).length) { + const cfg = configOutcome(g.env, g.addrValid); + expect(cfg.ok).toBe(false); + expectCliFailure(res, { exitCode: 2, usage: false }); + expect(matchesConfigError(errorMessage(res), cfg as Exclude), `cli garbage ${i}: ${errorMessage(res)}`).toBe(true); + } else { + const o = cliOutcome(g.args, model); + if (o.kind !== 'error') throw new Error('garbage generator produced a successful command'); + expectCliFailure(res, { exitCode: o.exitCode, message: o.message, usage: o.usage }); + } + }); + const lock = (async () => { + await pollUntil('attack traffic under way', () => (fuzz.cases.length > 100 ? true : undefined), { timeoutMs: 30_000, intervalMs: 5 }); + const db = new DatabaseSync(ws.dbPath, { timeout: 3000 }); + db.exec('BEGIN IMMEDIATE'); + await new Promise((res) => setTimeout(res, 2000)); + db.exec('ROLLBACK'); + db.close(); + })(); + await Promise.all([enumeration, near, fuzzing, garbage, lock]); + for (const t of audits) model.audit(t); + model.verifyDb(ws.dbPath, 'after the attack: only legitimately authenticated requests left a trace'); + }); + + // ---- 3. crash in the middle of a burst, restart on the same database + await test.step('SIGKILL during a burst of 80 authenticated requests, then restart: committed rows survive, nothing is half-written', async () => { + const victim = model.active()[0]; + const before = readOperations(ws.dbPath).filter((o) => o.token === victim).length; + fake.delay(5); // keep requests in flight + let done = 0; + const burst = mapLimit(Array.from({ length: 80 }, () => 0), 10, async () => { + const res = await http.raw({ host: server.connectHost, port: server.port, data: rawBytes({ cls: 'burst', method: 'GET', target: '/api/v1/containers', headers: [['Authorization', `Bearer ${victim}`]] }), timeoutMs: 20_000 }); + done++; + return res; + }); + await pollUntil('a third of the burst answered', () => (done >= 25 ? true : undefined), { timeoutMs: 30_000, intervalMs: 2 }); + const killed = await server.kill(); + expect(killed.signal).toBe('SIGKILL'); + const results = await burst; + const ok = results.filter((x) => x.outcome === 'response' && x.response!.status === 200).length; + const lost = results.filter((x) => x.outcome !== 'response').length; + expect(results.every((x) => x.outcome !== 'response' || x.response!.status === 200), 'answered requests all succeeded').toBe(true); + expect(ok + lost).toBe(80); + expect(lost, 'the kill really interrupted the burst').toBeGreaterThan(0); + fake.delay(0); + await server.restart(); // new process, same DB/port/backend: WAL recovery + const after = readOperations(ws.dbPath).filter((o) => o.token === victim).length - before; + // every answered request is durably audited; interrupted ones may or may not have committed their row + expect(after).toBeGreaterThanOrEqual(ok); + expect(after).toBeLessThanOrEqual(ok + lost); + model.audit(victim, after); + model.verifyDb(ws.dbPath, 'after crash recovery'); + // `audit all` on the recovered database: every committed row, oldest first, byte-exact + const all = await cli.audit('all'); + expect(all, 'audit all after recovery: every row').toHaveLength(model.auditTotal); + expect(formatAuditOutput(all)).toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true })); + test.info().annotations.push({ type: 'crash', description: `${ok} answered, ${lost} interrupted, ${after - ok} interrupted request(s) had committed their audit row` }); + }); + + // ---- 4. normal administration on the recovered instance + await test.step('normal administration: list, create, rotate, revoke, use with every client, graceful restart', async () => { + model.verifyList(await cli.tokenList()); + model.verifyList(await cli.tokenListAll(), 'token list all after the incident', 'all'); + for (let i = 0; i < 5; i++) model.add(await cli.tokenNew(`after incident ${i}`), `after incident ${i}`); + for (let i = 0; i < 3; i++) { + const old = r.pick(model.active()); + const next = model.add(await cli.tokenNew(`rotated ${i}`), `rotated ${i}`).token; + await cli.tokenDelete(old); + model.deactivate(old); + expectErrorResponse(await http.get(server.url(), { token: old }), 401); + expectContainersResponse(await http.curl({ url: server.url(), token: next })); + model.audit(next); + } + for (const t of model.active()) { + expectContainersResponse(await http.get(server.url(), { token: t })); + model.audit(t); + } + const listing = (await cli.run(['token', 'list', 'all'])).stdout; + const history = (await cli.run(['audit', 'all'])).stdout; + expect(history, 'audit all: the whole history').toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true })); + await server.restart(); + expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(listing); + expect((await cli.run(['audit', 'all'])).stdout, 'audit all survives a graceful restart').toBe(history); + for (const t of model.inactive()) expectErrorResponse(await http.get(server.url(), { token: t }), 401); + }); + expect((await server.stop()).exitCode).toBe(0); + for (const g of server.allOutput()) expectNoTokenFragments(g.stderr, secrets, `server generation ${g.generation}`); + expect(server.logs().count({ level: ['WARN', 'ERROR'] }), 'the final generation saw no errors').toBe(0); + model.verifyDb(ws.dbPath, 'final'); + expect(stateDigest(ws.dbPath)).not.toBe('absent'); +}); diff --git a/e2e/tests/host/persona-fuzzer-user.spec.ts b/e2e/tests/host/persona-fuzzer-user.spec.ts new file mode 100644 index 0000000..eac7252 --- /dev/null +++ b/e2e/tests/host/persona-fuzzer-user.spec.ts @@ -0,0 +1,540 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 Bryan Joshua Pedini + +/** + * PLAN §12 persona "Fuzzer user": seeded, reproducible high-cardinality input across CLI argv, + * descriptions, tokens, configuration variables (CLI and server mode), methods, targets, query + * strings, headers, Authorization values, token populations, container populations and + * concurrent/pipelined/fragmented request patterns. + * + * Every case is generated from the test's seed (run seed DPSAPI_E2E_SEED + title path, annotated + * as `seed`) and recorded with `fuzz.record`; on failure the cases are attached as fuzz-cases.json. + * Outcomes are checked against exact oracles (harness/personas.ts) derived from the README + * contract: exit code + error line for the CLI, status + exact body for HTTP, audit rows per token, + * logged request/auth-class multisets. Malformed-by-construction requests have explicit allowed + * Go-level status sets. After every batch the database is checked against the model and the + * structural invariants, so the process must survive, stay bounded and keep serving valid traffic. + */ +import { + test, + expect, + errorMessage, + expectCliFailure, + expectCliSuccess, + expectContainersResponse, + goUnquoteBytes, + makeContainerSummary, + mapLimit, + msToNanos, + parseAuditOutput, + parseLogs, + parseTokenList, + readOperations, + readToken, + safeStringify, + USAGE_TEXT, + tokenId, + type CliResult, + type ContainerSummary, + type CurlResult, + type HttpResult, + type RawHttpResponse, + type Rng, +} from '../../harness'; +import { + StateModel, + TOKEN_CHARS, + checkResponse, + cliOutcome, + configOutcome, + expectAuditWindow, + expectHttp, + expectedAuditStdout, + expectedTokenListStdout, + fuzzAddr, + fuzzAuditArgs, + fuzzLogLevel, + fuzzPort, + fuzzRequest, + fuzzText, + isTokenChars, + isValidLogLevel, + isValidPort, + malformedRequest, + matchesConfigError, + nearMatches, + onlyResponse, + rawBytes, + routeOf, + stateDigest, + type AppEnv, + type HttpCase, + type HttpExpectation, + AUDIT_ALL_ARGV_NEAR_MISSES, + HELP_NEAR_MISSES, + LIST_ALL_NEAR_MISSES, +} from '../../harness/personas'; + +/** Compact, artifact-friendly rendering of a fuzz value (long values are summarized). */ +function show(v: string): string | { length: number; head: string } { + return v.length <= 160 ? v : { length: v.length, head: v.slice(0, 80) }; +} + +const MISSPELLED_COMMANDS = ['Token', 'tokens', 'TOKEN', 'tokn', 'list', 'new', 'delete', 'help', '-h', '--help', '', 'token ', ' token', 'tok\u0435n', 'token\n', 'AUDIT', 'Audit', 'audits', 'audit ', ' audit', 'aud\u0456t', 'log']; +const MISSPELLED_SUBCOMMANDS = ['lst', 'ls', 'neww', 'nwe', 'delet', 'del', 'rm', 'remove', 'LIST', 'New', 'DELETE', '', ' list', 'list ', 'new\t', 'create', 'revoke']; + +/** Seeded argv for the CLI fuzzer. */ +function genArgs(r: Rng, model: StateModel): string[] { + if (r.bool(0.15)) return ['audit', ...fuzzAuditArgs(r).args]; + // `help`, `token list all` and `audit all`, their near misses (case, blanks, look-alikes, extra arguments) + if (r.bool(0.14)) { + const q = r.next(); + if (q < 0.3) return ['help']; + if (q < 0.45) return ['token', 'list', 'all']; + if (q < 0.6) return ['audit', 'all']; + return [...r.pick([...HELP_NEAR_MISSES, ...LIST_ALL_NEAR_MISSES, ...AUDIT_ALL_ARGV_NEAR_MISSES])]; + } + const p = r.next(); + const tail = () => Array.from({ length: r.int(0, 2) }, () => fuzzText(r, { maxLen: 512 }).value); + if (p < 0.07) return [r.pick(MISSPELLED_COMMANDS), ...tail()]; + if (p < 0.1) return [fuzzText(r, { maxLen: 512 }).value, ...tail()]; + if (p < 0.13) return ['token']; + if (p < 0.2) return ['token', r.bool(0.6) ? r.pick(MISSPELLED_SUBCOMMANDS) : fuzzText(r, { maxLen: 256 }).value, ...tail()]; + const sub = r.pick(['list', 'new', 'new', 'new', 'delete', 'delete', 'delete']); + if (sub === 'list') { + const q = r.next(); + if (q < 0.6) return ['token', 'list']; + if (q < 0.8) return ['token', 'list', 'all']; + return ['token', 'list', ...Array.from({ length: r.int(1, 2) }, () => fuzzText(r, { maxLen: 64 }).value)]; + } + if (sub === 'new') { + const q = r.next(); + if (q < 0.1) return ['token', 'new']; + if (q < 0.9) return ['token', 'new', fuzzText(r, { maxLen: r.bool(0.05) ? 131_071 : 4096 }).value]; + return ['token', 'new', ...Array.from({ length: r.int(2, 3) }, () => fuzzText(r, { maxLen: 64 }).value)]; + } + const q = r.next(); + if (q < 0.08) return ['token', 'delete']; + if (q < 0.15) return ['token', 'delete', ...Array.from({ length: r.int(2, 3) }, () => (model.active().length && r.bool() ? r.pick(model.active()) : fuzzText(r, { maxLen: 64 }).value))]; + const active = model.active(); + const inactive = model.inactive(); + const v = r.next(); + if (v < 0.35 && active.length) return ['token', 'delete', r.pick(active)]; + if (v < 0.5 && inactive.length) return ['token', 'delete', r.pick(inactive)]; + if (v < 0.75 && model.all().length) return ['token', 'delete', nearMatches(r.pick(model.all()), r, 1)[0]?.value ?? 'x']; + return ['token', 'delete', r.bool(0.3) ? r.string(43, TOKEN_CHARS) : fuzzText(r, { maxLen: 256 }).value]; +} + +/** argv mentioning the word `all` in any case / look-alike form (`audit ALL`, `token list all x`...). */ +const mentionsAll = (args: string[]) => args.some((a) => /[a\u0430]ll/i.test(a)); + +/** Seeded configuration variables (each independently set with some probability). */ +function genEnv(r: Rng): { env: AppEnv; addrValid?: boolean } { + if (r.bool(0.65)) return { env: {} }; + const env: AppEnv = {}; + let addrValid: boolean | undefined; + if (r.bool(0.4)) { + const a = fuzzAddr(r); + env.HTTP_ADDR = a.value; + addrValid = a.valid; + } + if (r.bool(0.5)) env.HTTP_PORT = fuzzPort(r); + if (r.bool(0.6)) env.LOG_LEVEL = fuzzLogLevel(r); + return { env, addrValid }; +} + +/** Stderr of a successful verbose run must consist of well-formed slog records only. */ +function expectOnlyLogRecords(r: CliResult, label: string): void { + const logs = parseLogs(r.stderr); + expect(logs.unparsed.map((u) => u.text.slice(0, 200)), `${label}: every stderr line is a slog record`).toEqual([]); +} + +test('CLI: 300 seeded argv/environment cases with an exact outcome oracle; database always matches the model', async ({ cli, ws, fuzz, secrets, startServer, http }) => { + test.setTimeout(150_000); + const r = fuzz.rng.fork('cli'); + const model = new StateModel(); + // starting population so delete/near-match cases have targets + for (let i = 0; i < 6; i++) { + const d = fuzzText(r, { maxLen: 256 }).value; + model.add(await cli.tokenNew(d), d); + } + // audit history (real authenticated requests) so `audit` selections are non-trivial + const server = await startServer(); + for (let i = 0; i < 24; i++) { + const t = r.pick(model.active()); + expectContainersResponse(await http.get(server.url(), { token: t })); + model.audit(t); + } + expect((await server.stop()).exitCode).toBe(0); + let auditRuns = 0; + let auditUsageErrors = 0; + let helpRuns = 0; + let helpListUsageErrors = 0; + let auditAllRuns = 0; + let auditAllUsageErrors = 0; + const listRuns = { active: 0, all: 0 }; + const N = 300; + for (let i = 0; i < N; i++) { + // every 30th case is `audit all` or one of its near misses, so both are always exercised + const forced = i % 30 === 5 ? ['audit', 'all'] : i % 30 === 20 ? [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES.filter((a) => a[0] === 'audit'))] : undefined; + const viaShell = !forced && r.bool(0.06); + if (viaShell) { + // Invalid UTF-8 can only reach argv/env through raw bytes: a real shell with printf octal escapes. + const bytes = Buffer.from(r.bytes(r.int(1, 24)).map((b) => (b === 0 ? 0xff : b))); + if (bytes[bytes.length - 1] === 0x0a) bytes[bytes.length - 1] = 0xc0; // $(...) strips trailing newlines + const fmt = [...bytes].map((b) => '\\' + b.toString(8).padStart(3, '0')).join(''); + const intoEnv = r.bool(0.4); + const c = fuzz.record({ i, via: 'shell', intoEnv, bytesHex: bytes.toString('hex') }); + const before = stateDigest(ws.dbPath); + if (intoEnv) { + const res = await cli.shell(`HTTP_PORT="$(printf '${fmt}')" "$DPSAPI_BIN" token list`); + expectCliFailure(res, { exitCode: 2, usage: false }); + const msg = errorMessage(res)!; + const m = /^configuration error: invalid HTTP_PORT (".*"): must be an integer between 1 and 65535$/.exec(msg); + expect(m, `case ${i}: ${JSON.stringify(c)} → ${msg}`).not.toBeNull(); + expect(goUnquoteBytes(m![1]).equals(bytes), `case ${i}: value quoted byte-exact`).toBe(true); + expect(stateDigest(ws.dbPath)).toBe(before); + } else { + const res = await cli.shell(`"$DPSAPI_BIN" token new "$(printf '${fmt}')"`); + expectCliSuccess(res, { stderr: '' }); + expect(res.stdout).toMatch(/^[A-Za-z0-9_-]{43}\n$/); + const tok = res.stdout.trim(); + model.add(tok, bytes); + expect(readToken(ws.dbPath, tok)!.descriptionHex, `case ${i}: invalid UTF-8 stored byte-exact`).toBe(bytes.toString('hex').toUpperCase()); + } + continue; + } + + const args = forced ?? genArgs(r, model); + const { env, addrValid } = genEnv(r); + const cfg = configOutcome(env, addrValid); + const outcome = cfg.ok ? cliOutcome(args, model) : undefined; + const c = fuzz.record({ i, args: args.map(show), env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, show(v!)])), expected: cfg.ok ? outcome : cfg }); + const label = `case ${i} ${safeStringify(c).slice(0, 400)}`; + const verbose = cfg.ok && env.LOG_LEVEL !== undefined && /^(debug|trace)$/i.test(env.LOG_LEVEL); + const before = stateDigest(ws.dbPath); + const t0 = msToNanos(Date.now() - 1); + const res = await cli.run(args, { env }); + const t1 = msToNanos(Date.now() + 1); + + if (!cfg.ok) { + // configuration errors are reported before the logger exists: exactly one stderr line + expectCliFailure(res, { exitCode: 2, usage: false }); + expect(matchesConfigError(errorMessage(res), cfg), `${label}: got ${JSON.stringify(errorMessage(res))}`).toBe(true); + expect(stateDigest(ws.dbPath), `${label}: state unchanged`).toBe(before); + continue; + } + switch (outcome!.kind) { + case 'list': { + expectCliSuccess(res); + if (!verbose) expect(res.stderr, label).toBe(''); + else expectOnlyLogRecords(res, label); + model.verifyList(parseTokenList(res.stdout), label, outcome!.scope); + expect(res.stdout, `${label}: exact padded table of the stored rows`).toBe(expectedTokenListStdout(ws.dbPath, outcome!.scope)); + expect(stateDigest(ws.dbPath), `${label}: list is read-only`).toBe(before); + listRuns[outcome!.scope]++; + break; + } + case 'help': { + expectCliSuccess(res, { stdout: USAGE_TEXT }); + if (!verbose) expect(res.stderr, label).toBe(''); + else expectOnlyLogRecords(res, label); + expect(stateDigest(ws.dbPath), `${label}: help is read-only`).toBe(before); + helpRuns++; + break; + } + case 'new': { + expectCliSuccess(res); + expect(res.stdout, label).toMatch(/^[A-Za-z0-9_-]{43}\n$/); + if (!verbose) expect(res.stderr, label).toBe(''); + else expectOnlyLogRecords(res, label); + const tok = secrets.register(res.stdout.trim()); + model.add(tok, outcome!.description); + break; + } + case 'delete': { + expectCliSuccess(res, { stdout: '' }); + if (!verbose) expect(res.stderr, label).toBe(''); + else expectOnlyLogRecords(res, label); + model.deactivate(outcome!.token); + break; + } + case 'audit': { + expectCliSuccess(res); + if (!verbose) expect(res.stderr, label).toBe(''); + else expectOnlyLogRecords(res, label); + const sel = outcome!.selection; + const ops = readOperations(ws.dbPath); + if ('windowNs' in sel) expectAuditWindow(parseAuditOutput(res.stdout), ops, sel.windowNs, t0, t1, label); + else expect(res.stdout, label).toBe(expectedAuditStdout(ops, sel)); + expect(stateDigest(ws.dbPath), `${label}: audit is read-only`).toBe(before); + auditRuns++; + if ('all' in sel) { + expect(parseAuditOutput(res.stdout), `${label}: audit all prints every row`).toHaveLength(model.auditTotal); + auditAllRuns++; + } + break; + } + case 'error': { + expectCliFailure(res, { exitCode: outcome!.exitCode, message: outcome!.message, usage: outcome!.usage, exactStderr: !verbose }); + if (verbose) { + // nothing but log records before the error line (no echo of any argument) + const head = outcome!.usage ? res.stderr.slice(0, res.stderr.length - USAGE_TEXT.length) : res.stderr; + expect(parseLogs(head).unparsed.map((u) => u.text.slice(0, 120)), `${label}: only the error line is not a log record`).toEqual([`dpsapi: ${outcome!.message}`]); + } + if (args[0] === 'audit') auditUsageErrors++; + if (args[0] === 'help' || (args[0] === 'token' && args[1] === 'list')) helpListUsageErrors++; + if (args[0] === 'audit' && mentionsAll(args.slice(1))) auditAllUsageErrors++; + expect(stateDigest(ws.dbPath), `${label}: failed command left the state unchanged`).toBe(before); + const deleted = args[1] === 'delete' ? args[2] : undefined; + if (deleted && /^[A-Za-z0-9_-]{40,}$/.test(deleted)) expect(res.stderr.includes(deleted), `${label}: supplied token never echoed`).toBe(false); + break; + } + } + if (i % 20 === 19) model.verifyDb(ws.dbPath, `after case ${i}`); + } + model.verifyDb(ws.dbPath, 'final state'); + model.verifyList(await cli.tokenList(), 'final token list'); + model.verifyList(await cli.tokenListAll(), 'final token list all', 'all'); + expect(model.tokens.size, 'the fuzzer actually created tokens').toBeGreaterThan(20); + expect(model.inactive().length, 'the fuzzer actually deleted tokens').toBeGreaterThan(3); + expect(auditRuns, 'the fuzzer ran valid audit selections').toBeGreaterThan(8); + expect(auditUsageErrors, 'the fuzzer produced invalid audit arguments').toBeGreaterThan(8); + expect(helpRuns, 'the fuzzer ran help').toBeGreaterThan(2); + expect(listRuns.active, 'the fuzzer ran token list').toBeGreaterThan(5); + expect(listRuns.all, 'the fuzzer ran token list all').toBeGreaterThan(2); + expect(helpListUsageErrors, 'the fuzzer produced help / token list usage errors').toBeGreaterThan(5); + expect(auditAllRuns, 'the fuzzer ran audit all').toBeGreaterThan(3); + expect(auditAllUsageErrors, 'the fuzzer produced audit all near misses').toBeGreaterThan(3); + test.info().annotations.push({ type: 'audit fuzz', description: `${auditRuns} valid audit invocations (${auditAllRuns} audit all), ${auditUsageErrors} audit usage errors (${auditAllUsageErrors} near misses of audit all)` }); + test.info().annotations.push({ type: 'help/list fuzz', description: `${helpRuns} help, ${listRuns.active} token list, ${listRuns.all} token list all, ${helpListUsageErrors} help/list usage errors` }); +}); + +type Transport = 'raw' | 'raw-fragmented' | 'playwright' | 'curl'; + +function playwrightEligible(c: HttpCase, base: string): boolean { + if (!['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(c.method) || c.rawLines || c.goReject || routeOf(c.target) === 'bad') return false; + const names = c.headers.map(([n]) => n.toLowerCase()); + if (new Set(names).size !== names.length) return false; + // eslint-disable-next-line no-control-regex + if (!c.headers.every(([, v]) => /^[\x20-\x7e]*$/.test(v) && v === v.trim())) return false; + try { + const u = new URL(base + c.target); + return u.pathname + u.search === c.target; // WHATWG URL parsing must not rewrite the target + } catch { + return false; + } +} + +function curlEligible(c: HttpCase): boolean { + if (c.rawLines || c.goReject || c.target.length > 2000) return false; + // curl cannot combine -I with a body; Go-rejected targets close the connection with the body unread (RST) + if ((c.method === 'HEAD' && c.body !== undefined) || routeOf(c.target) === 'bad' || !isTokenChars(c.method)) return false; + if (!/^[A-Za-z0-9\-._~%/?=&;:@!$'()*+,]+$/.test(c.target)) return false; + return c.headers.every(([n, v]) => /^[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?$/.test(v) && !/^(accept|user-agent)$/i.test(n)); +} + +test('HTTP: 1000+ seeded requests across methods, targets, headers and Authorization with an exact oracle, evolving token and container populations', async ({ cli, startServer, http, fuzz, secrets }) => { + test.setTimeout(240_000); + const r = fuzz.rng.fork('http'); + const model = new StateModel(); + const descs = Array.from({ length: 24 }, () => fuzzText(r, { maxLen: 512 }).value); + const toks = await mapLimit(descs, 8, (d) => cli.tokenNew(d)); + toks.forEach((t, i) => model.add(t, descs[i])); + for (const t of r.shuffle(toks).slice(0, 7)) { + await cli.tokenDelete(t); + model.deactivate(t); + } + const server = await startServer({ logLevel: 'DEBUG' }); + const fake = server.fakeDocker!; + const host = { host: server.connectHost, port: server.port }; + + let reached = 0; + let dockerErrors = 0; + const expectedClasses: string[] = []; + const statusesReached: number[] = []; + let sent = 0; + const seen = new Map(); + + const BATCHES = 20; + for (let b = 0; b < BATCHES; b++) { + // container population and backend health for this batch + const n = r.pick([0, 0, 1, 2, r.int(3, 50), r.int(50, 400)]); + const containers: ContainerSummary[] = Array.from({ length: n }, (_, i) => + makeContainerSummary(i, r, { Labels: { 'fuzz.batch': String(b), 'fuzz.text': fuzzText(r, { maxLen: 64 }).value }, Names: [`/fuzz-${b}-${i}`] }), + ); + const mode = r.pick(['ok', 'ok', 'ok', 'ok', 'ok', 'error', 'destroy', 'badjson'] as const); + fake.setContainers(containers); + if (mode === 'error') fake.error500(); + else if (mode === 'destroy') fake.destroy(); + else if (mode === 'badjson') fake.badJson(r.pick(['{', '{"a":1}', '[{"Id":', '', '"text"', '42'])); + const backendOk = mode === 'ok'; + const concurrency = r.int(1, 16); + + // the batch: well-formed singles, malformed singles, pipelines + type Unit = { kind: 'single'; c: HttpCase; t: Transport } | { kind: 'pipeline'; cs: HttpCase[] }; + const units: Unit[] = []; + for (let i = 0; i < 50; i++) { + const p = r.next(); + if (p < 0.06) { + const cs: HttpCase[] = []; + while (cs.length < r.int(2, 5)) { + const c = fuzzRequest(r, model); + const e = expectHttp(c, (t) => model.lookup(t), backendOk); + if (e.reachesApp && c.method !== 'HEAD') cs.push(c); + } + units.push({ kind: 'pipeline', cs }); + continue; + } + const c = p < 0.16 ? malformedRequest(r, model) : fuzzRequest(r, model); + const options: Transport[] = ['raw', 'raw', 'raw', 'raw-fragmented']; + if (playwrightEligible(c, server.baseURL)) options.push('playwright', 'playwright'); + if (curlEligible(c)) options.push('curl'); + units.push({ kind: 'single', c, t: r.pick(options) }); + } + + const audits: string[] = []; + const tally = (exp: HttpExpectation, status: number): void => { + seen.set(status, (seen.get(status) ?? 0) + 1); + if (exp.reachesApp) { + reached++; + statusesReached.push(status); + } + if (exp.authClass) expectedClasses.push(exp.authClass); + if (exp.audited && (status === 200 || status === 500)) audits.push(exp.audited); + if (status === 500) dockerErrors++; + }; + await mapLimit(units, concurrency, async (u, ui) => { + const label = `batch ${b} unit ${ui}`; + if (u.kind === 'pipeline') { + const exps = u.cs.map((c) => expectHttp(c, (t) => model.lookup(t), backendOk)); + fuzz.record({ b, ui, transport: 'pipeline', cases: u.cs.map((c) => ({ method: c.method, target: show(c.target), headers: c.headers.map(([k, v]) => [k, show(v)]) })), expected: exps }); + const data = Buffer.concat(u.cs.map((c, i) => rawBytes(c, { keepAlive: i < u.cs.length - 1 }))); + const res = await http.raw({ ...host, data, expectResponses: u.cs.length, timeoutMs: 20_000 }); + expect(res.responses.length, `${label}: one response per pipelined request`).toBe(u.cs.length); + res.responses.forEach((resp, i) => { + checkResponse(resp, exps[i], { containers: n, secrets, label: `${label}#${i}` }); + tally(exps[i], resp.status); + }); + sent += u.cs.length; + return; + } + const { c, t } = u; + const exp = expectHttp(c, (tok) => model.lookup(tok), backendOk); + fuzz.record({ b, ui, transport: t, cls: c.cls, method: show(c.method), target: show(c.target), headers: c.headers.map(([k, v]) => [k, show(v)]), rawLines: c.rawLines?.slice(0, 3).map(show), body: c.body === undefined ? undefined : show(c.body), expected: exp }); + let res: RawHttpResponse | HttpResult | CurlResult; + const head = c.method === 'HEAD'; + if (t === 'raw' || t === 'raw-fragmented') { + // Requests with a body are sent keep-alive: with `Connection: close` Go answers before + // reading the body and closes with unread bytes (RST), which can destroy the response in + // flight on the client side — a transport artefact, not an application outcome. + const bytes = rawBytes(c, { keepAlive: c.body !== undefined }); + let data: Buffer | Buffer[] = bytes; + if (t === 'raw-fragmented') { + const cuts = [...new Set(Array.from({ length: r.int(1, 6) }, () => r.int(1, bytes.length - 1)))].sort((a, z) => a - z); + data = [0, ...cuts].map((s, k) => bytes.subarray(s, cuts[k] ?? bytes.length)); + } + res = onlyResponse(await http.raw({ ...host, data, chunkDelayMs: t === 'raw-fragmented' ? 3 : undefined, head, timeoutMs: 20_000 }), label); + } else if (t === 'playwright') { + res = await http.fetch(server.url(c.target), { method: c.method, headers: Object.fromEntries(c.headers), data: c.body }); + } else { + res = await http.curl({ url: server.url(c.target), method: head ? undefined : c.method, head, headers: c.headers.map(([k, v]) => `${k}: ${v}`), data: c.body, pathAsIs: true }); + expect((res as CurlResult).exitCode, `${label}: curl exit`).toBe(0); + } + checkResponse(res, exp, { head, containers: n, secrets, label }); + tally(exp, res.status); + sent++; + }); + + for (const t of audits) model.audit(t); + model.verifyDb(server.dbPath, `after batch ${b} (${mode}, ${n} containers, concurrency ${concurrency})`); + + // token population evolves between batches + if (r.bool(0.6) && model.active().length > 3) { + const victim = r.pick(model.active()); + await cli.tokenDelete(victim); + model.deactivate(victim); + } + if (r.bool(0.6)) { + const d = fuzzText(r, { maxLen: 256 }).value; + model.add(await cli.tokenNew(d), d); + } + } + + // the server is still fully functional + fake.many(3); + const last = model.active()[0]; + expect(expectContainersResponse(await http.get(server.url(), { token: last }))).toHaveLength(3); + model.audit(last); + expect((await server.stop()).exitCode).toBe(0); + + const logs = server.logs(); + expect(sent, 'requests sent').toBeGreaterThanOrEqual(1000); + for (const st of [200, 400, 401, 404, 405, 500]) expect(seen.get(st) ?? 0, `the fuzzer produced status ${st}`).toBeGreaterThan(0); + test.info().annotations.push({ type: 'status distribution', description: JSON.stringify(Object.fromEntries([...seen].sort((a, z) => a[0] - z[0]))) }); + expect(logs.requests().length, 'every request that reached the app was logged exactly once').toBe(reached + 1); + expect( + logs.requests().map((x) => Number(x.attrs.status)).sort(), + 'logged statuses equal the observed ones', + ).toEqual([...statusesReached, 200].sort()); + expect(logs.filter({ msg: 'authentication result' }).map((x) => x.attrs.result).sort(), 'authentication classes equal the oracle').toEqual([...expectedClasses, 'ok'].sort()); + expect(logs.count({ level: 'ERROR', msg: 'docker request failed' }), 'one ERROR per Docker failure').toBe(dockerErrors); + expect(logs.count({ level: 'ERROR' })).toBe(dockerErrors); + expect(logs.count({ level: 'WARN' })).toBe(0); + model.verifyDb(server.dbPath, 'final state'); + // every token id in the auth log belongs to a known token: nothing unknown was ever authenticated + const known = new Set(model.all().map(tokenId)); + expect(logs.filter({ msg: 'authentication result', attrs: { result: 'ok' } }).every((x) => known.has(x.attrs.token_id))).toBe(true); +}); + +test('environment: 60 seeded HTTP_ADDR/HTTP_PORT/LOG_LEVEL combinations in server mode — exit 2 with the exact message, or a working server', async ({ cli, ws, startServer, http, e2e, fuzz }) => { + test.setTimeout(150_000); + const r = fuzz.rng.fork('env'); + const token = await cli.tokenNew('env fuzz'); + const loopbacks = ['127.0.0.1', '::1', '127.0.0.2', '127.255.255.254', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']; + let started = 0; + let rejected = 0; + for (let i = 0; i < 60; i++) { + const digest = stateDigest(ws.dbPath); + const port = await e2e.allocatePort(); + const env: AppEnv = {}; + let addrValid = true; + // addresses: valid loopbacks only when they would be bound; otherwise invalid values + if (r.bool(0.55)) env.HTTP_ADDR = r.pick(loopbacks); + else { + const a = fuzzAddr(r); + env.HTTP_ADDR = a.valid ? r.pick(loopbacks) : a.value; + addrValid = a.valid; + } + env.HTTP_PORT = r.bool(0.55) ? '0'.repeat(r.pick([0, 0, 1, 5])) + String(port) : fuzzPort(r); + if (isValidPort(env.HTTP_PORT) && Number(env.HTTP_PORT) !== port) env.HTTP_PORT = String(port); // never bind a port we did not allocate + if (r.bool(0.8)) env.LOG_LEVEL = r.bool(0.6) ? r.pick(['error', 'Warn', 'INFO', 'debug', 'TrAcE']) : fuzzLogLevel(r); + const cfg = configOutcome(env, addrValid); + fuzz.record({ i, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, show(v!)])), expected: cfg }); + const label = `case ${i} ${JSON.stringify(env).slice(0, 300)}`; + if (!cfg.ok) { + const res = await cli.run([], { env, timeoutMs: 10_000 }); + expectCliFailure(res, { exitCode: 2, usage: false }); + expect(matchesConfigError(errorMessage(res), cfg), `${label}: got ${JSON.stringify(errorMessage(res))}`).toBe(true); + expect(stateDigest(ws.dbPath), `${label}: database untouched`).toBe(digest); + rejected++; + continue; + } + const server = await startServer({ addr: env.HTTP_ADDR, port: env.HTTP_PORT, logLevel: env.LOG_LEVEL ?? null, label: `env${i}` }); + const res = await http.get(server.url(), { token }); + expectContainersResponse(res); + const exit = await server.stop(); + expect(exit.exitCode, label).toBe(0); + const level = (env.LOG_LEVEL ?? 'INFO').toUpperCase(); + expect(isValidLogLevel(level)).toBe(true); + const logs = server.logs(); + expect(logs.unparsed, `${label}: stderr is only slog records`).toEqual([]); + const rank = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].indexOf(level); + expect(logs.requests().length, `${label}: request record visible iff level <= INFO`).toBe(rank <= 2 ? 1 : 0); + expect(logs.levels().every((l) => ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].indexOf(l) >= rank), `${label}: nothing below ${level}`).toBe(true); + if (rank <= 2) expect(logs.find({ msg: 'server listening' })!.attrs.addr).toMatch(new RegExp(`:${port}$`)); + started++; + } + expect(started, 'some combinations were valid').toBeGreaterThan(5); + expect(rejected, 'some combinations were invalid').toBeGreaterThan(5); +}); diff --git a/e2e/tests/host/persona-malicious-user.spec.ts b/e2e/tests/host/persona-malicious-user.spec.ts new file mode 100644 index 0000000..f12857f --- /dev/null +++ b/e2e/tests/host/persona-malicious-user.spec.ts @@ -0,0 +1,935 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 Bryan Joshua Pedini + +/** + * PLAN §12 persona "Malicious user": attempts to bypass authentication, obtain information, + * corrupt state or cause unsafe behaviour through every interface that exists (HTTP via + * Playwright / curl / raw socket, the CLI, the shared SQLite file, the Docker socket). + * + * Every test asserts: the exact status/exit outcome; no token secret (or 12-char fragment of + * one) in any response or log; no internal detail in HTTP bodies; exact audit rows; structurally + * valid database (assertDbInvariants) and, where the attack must not change anything, a + * byte-identical logical state (stateDigest). + */ +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import { + test, + expect, + assertDbInvariants, + expectCliFailure, + expectCliSuccess, + expectContainersResponse, + expectErrorResponse, + expectNoInternalDetails, + fileDigest, + goQuote, + mapLimit, + msToNanos, + parseAuditOutput, + parseLogs, + parseTokenList, + pollUntil, + readOperations, + readSchema, + readToken, + rfc3339ToNanos, + shellQuote, + tokenId, + USAGE_TEXT, + type HttpClient, + type RawResult, + type ServerHandle, +} from '../../harness'; +import { + ENDPOINT, + SQL_PAYLOADS, + StateModel, + TOKEN_CHARS, + auditArgsOutcome, + authOf, + expectedAuditStdout, + expectNoTokenFragments, + nearMatches, + onlyResponse, + routeOf, + stateDigest, +} from '../../harness/personas'; + +/** Raw request bytes (UTF-8): request line, Host, given header lines, optional Connection: close. */ +function req(lines: string[], { target = ENDPOINT, method = 'GET', version = 'HTTP/1.1', close = true, body }: { target?: string; method?: string; version?: string; close?: boolean; body?: string } = {}): Buffer { + const all = [`${method} ${target} ${version}`, 'Host: localhost', ...lines]; + if (body !== undefined) all.push(`Content-Length: ${Buffer.byteLength(body)}`); + if (close) all.push('Connection: close'); + return Buffer.from(all.join('\r\n') + '\r\n\r\n' + (body ?? ''), 'utf8'); +} + +function raw(http: HttpClient, s: ServerHandle, data: Buffer | string, extra: { expectResponses?: number; timeoutMs?: number } = {}): Promise { + return http.raw({ host: s.connectHost, port: s.port, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data, timeoutMs: 20_000, ...extra }); +} + +test('token enumeration: 1500 random guesses, near-matches, case/whitespace/confusable mutations and inactive tokens — only the documented OWS trim authenticates, responses are indistinguishable', async ({ cli, startServer, http, secrets, rng }) => { + test.setTimeout(120_000); + const model = new StateModel(); + for (let i = 0; i < 5; i++) model.add(await cli.tokenNew(`victim ${i}`), `victim ${i}`); + for (let i = 0; i < 4; i++) { + const t = model.add(await cli.tokenNew(`revoked ${i}`), `revoked ${i}`).token; + await cli.tokenDelete(t); + model.deactivate(t); + } + const server = await startServer({ logLevel: 'DEBUG' }); + server.fakeDocker!.many(2); + const lookup = (t: string) => model.lookup(t); + const expectedClasses: string[] = []; + + await test.step('1500 random 43-character guesses, 24 concurrent (Playwright)', async () => { + const guesses = Array.from({ length: 1500 }, () => rng.string(43, TOKEN_CHARS)); + const res = await mapLimit(guesses, 24, (g) => http.get(server.url(), { token: g })); + for (const r of res) expectErrorResponse(r, 401); + expectedClasses.push(...guesses.map(() => 'unknown_token')); + }); + + await test.step('near-match mutations of every active and inactive token (raw socket, UTF-8 bytes)', async () => { + const cases: Array<{ kind: string; header: string }> = []; + for (const t of model.all()) { + for (const m of nearMatches(t, rng, 30)) if (!/[\r\n]/.test(m.value)) cases.push({ kind: m.kind, header: `Bearer ${m.value}` }); + for (const v of [`bearer ${t}`, `BEARER ${t}`, ` Bearer ${t}`, `Bearer ${t}\t`, `Bearer ${t} `, `Bearer\u{a0}${t}`, `Bearer ${t}\u{200b}`, `B\u{435}arer ${t}`]) cases.push({ kind: 'scheme/whitespace', header: v }); + } + let oks = 0; + await mapLimit(cases, 16, async (c) => { + const exp = authOf([c.header], lookup); + const r = onlyResponse(await raw(http, server, req([`Authorization: ${c.header}`])), c.kind); + if (exp.cls === 'ok') { + // documented: HTTP itself trims leading/trailing whitespace of the header value (RFC 9110) + expectContainersResponse(r); + model.audit(exp.token!); + oks++; + } else expectErrorResponse(r, 401); + expectedClasses.push(exp.cls); + }); + expect(oks, 'only OWS-trimmed exact tokens of active tokens authenticated').toBe(cases.filter((c) => authOf([c.header], lookup).cls === 'ok').length); + expect(cases.length).toBeGreaterThan(300); + }); + + await test.step('inactive tokens hammered: 4 × 25 concurrent requests → 401 every time', async () => { + const attempts = model.inactive().flatMap((t) => Array.from({ length: 25 }, () => t)); + for (const r of await mapLimit(attempts, 20, (t) => http.get(server.url(), { token: t }))) expectErrorResponse(r, 401); + expectedClasses.push(...attempts.map(() => 'inactive_token')); + }); + + await test.step('unknown, inactive, malformed and missing credentials are indistinguishable to the client', async () => { + const shape = (r: RawResult) => { + const res = onlyResponse(r, 'shape'); + return { status: res.status, headers: res.headers.filter(([k]) => k.toLowerCase() !== 'date'), body: res.bodyText }; + }; + const variants = [ + req([`Authorization: Bearer ${rng.string(43, TOKEN_CHARS)}`]), + req([`Authorization: Bearer ${model.inactive()[0]}`]), + req(['Authorization: Basic Zm9vOmJhcg==']), + req([]), + req([`Authorization: Bearer ${model.active()[0]}`, `Authorization: Bearer ${model.active()[0]}`]), + ]; + const shapes = []; + for (const v of variants) shapes.push(shape(await raw(http, server, v))); + for (const s of shapes.slice(1)) expect(s).toEqual(shapes[0]); + expectedClasses.push('unknown_token', 'inactive_token', 'bad_scheme', 'missing_header', 'multiple_headers'); + }); + + // the legitimate users are unaffected + for (const t of model.active()) { + expect(expectContainersResponse(await http.get(server.url(), { token: t }))).toHaveLength(2); + model.audit(t); + expectedClasses.push('ok'); + } + expect((await server.stop()).exitCode).toBe(0); + const logs = server.logs(); + expect(logs.filter({ msg: 'authentication result' }).map((r) => r.attrs.result).sort(), 'every attempt was classified as the oracle predicts').toEqual([...expectedClasses].sort()); + expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0); + expectNoTokenFragments(server.stderr, secrets, 'server log'); + model.verifyDb(server.dbPath); +}); + +interface InjectionCase { + name: string; + data: Buffer; + /** Expected status of each response, in order; `go` marks responses produced by Go (not the app). */ + responses: Array<{ status: number; go?: boolean }>; + /** Tokens that must get one audit row each. */ + audits: string[]; +} + +test('header injection, smuggling, duplicate/conflicting/oversized headers and malformed HTTP syntax (raw socket)', async ({ cli, startServer, http, secrets }) => { + test.setTimeout(90_000); + const T = await cli.tokenNew('valid'); + const U = await cli.tokenNew('other'); + const I = await cli.tokenNew('revoked'); + await cli.tokenDelete(I); + const server = await startServer({ logLevel: 'DEBUG' }); + server.fakeDocker!.many(1); + const ok = { status: 200 }; + const c401 = { status: 401 }; + const go400 = { status: 400, go: true }; + const cases: InjectionCase[] = [ + { name: 'CR inside a value (attempted header split) → Go 400', data: req([`Authorization: Bearer junk\rAuthorization: Bearer ${T}`]), responses: [go400], audits: [] }, + { name: 'bare LF inside a value becomes a second Authorization header → 401 multiple', data: req([`Authorization: Bearer junk\nAuthorization: Bearer ${T}`]), responses: [c401], audits: [] }, + { name: 'obs-fold hides "Authorization:" inside X-A → 401 missing', data: req(['X-A: x', ` Authorization: Bearer ${T}`]), responses: [c401], audits: [] }, + { name: 'NUL inside the Authorization value → Go 400', data: req([`Authorization: Bearer ${T}\x00`]), responses: [go400], audits: [] }, + { name: 'NUL inside a header name → Go 400', data: req([`X-\x00: 1`, `Authorization: Bearer ${T}`]), responses: [go400], audits: [] }, + { name: 'space before the colon → Go 400', data: req([`Authorization : Bearer ${T}`]), responses: [go400], audits: [] }, + { name: 'valid + revoked under different name case → 401', data: req([`authorization: Bearer ${T}`, `AUTHORIZATION: Bearer ${I}`]), responses: [c401], audits: [] }, + { name: 'two different valid tokens → 401', data: req([`Authorization: Bearer ${T}`, `Authorization: Bearer ${U}`]), responses: [c401], audits: [] }, + { name: 'valid then empty Authorization → 401', data: req([`Authorization: Bearer ${T}`, 'Authorization:']), responses: [c401], audits: [] }, + { name: 'Authorization only in a chunked trailer → 401 missing', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n1\r\na\r\n0\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [c401], audits: [] }, + { name: 'Authorization smuggled in a GET body → 401 missing', data: req([], { body: `Authorization: Bearer ${T}\r\n`, close: false }), responses: [c401], audits: [] }, + { name: 'pipelined: valid, no auth, revoked on one connection → auth never carries over', data: Buffer.concat([req([`Authorization: Bearer ${T}`], { close: false }), req([], { close: false }), req([`Authorization: Bearer ${I}`])]), responses: [ok, c401, c401], audits: [T] }, + { + name: 'CL+TE request smuggling: the request after the chunk terminator is a separate, unauthenticated request', + data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${T}\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nGET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`), + responses: [ok, c401], + audits: [T], + }, + { name: 'PROXY protocol preamble spoofing the source → Go 400', data: Buffer.concat([Buffer.from('PROXY TCP4 6.6.6.6 127.0.0.1 1111 2222\r\n'), req([`Authorization: Bearer ${T}`])]), responses: [go400], audits: [] }, + { name: 'foreign Host header → served normally', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: evil.example:443\r\nAuthorization: Bearer ${T}\r\nConnection: close\r\n\r\n`), responses: [ok], audits: [T] }, + { name: 'absolute-form target to another host with a valid token → same endpoint, 200', data: req([`Authorization: Bearer ${T}`], { target: `http://evil.example${ENDPOINT}` }), responses: [ok], audits: [T] }, + { name: 'absolute-form target without credentials → 401', data: req([], { target: `http://evil.example${ENDPOINT}` }), responses: [c401], audits: [] }, + { name: 'Expect: 100-continue with a body → final response only, authenticated', data: req([`Authorization: Bearer ${T}`, 'Expect: 100-continue'], { body: 'ab', close: false }), responses: [ok], audits: [T] }, + { name: '1000 KiB extra header alongside a valid token (under the limit) → 200', data: req([`X-Pad: ${'p'.repeat(1000 * 1024)}`, `Authorization: Bearer ${T}`]), responses: [ok], audits: [T] }, + { name: 'headers over the 1 MiB limit → Go 431, never audited', data: req([`Authorization: Bearer ${T}`, `X-Pad: ${'p'.repeat(1100 * 1024)}`]), responses: [{ status: 431, go: true }], audits: [] }, + { name: '900 KiB bearer value → 401, value never logged', data: req([`Authorization: Bearer ${'Q'.repeat(900 * 1024)}`]), responses: [c401], audits: [] }, + { name: '490 header lines + valid token → 200', data: req([...Array.from({ length: 490 }, (_, i) => `X-H${i}: v`), `Authorization: Bearer ${T}`]), responses: [ok], audits: [T] }, + { name: '600 header lines (over Go\'s header count limit) → Go 431', data: req([`Authorization: Bearer ${T}`, ...Array.from({ length: 600 }, (_, i) => `X-H${i}: v`)]), responses: [{ status: 431, go: true }], audits: [] }, + { name: 'HTTP/1.0 without Host → 200', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.0\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [ok], audits: [T] }, + { name: 'HTTP/0.9-style request line → Go 400', data: Buffer.from(`GET ${ENDPOINT}\r\n\r\n`), responses: [go400], audits: [] }, + { name: 'unsupported HTTP version → Go 505', data: req([`Authorization: Bearer ${T}`], { version: 'HTTP/9.9' }), responses: [{ status: 505, go: true }], audits: [] }, + { name: 'lower-case method → 405 before authentication', data: req([`Authorization: Bearer ${T}`], { method: 'get' }), responses: [{ status: 405 }], audits: [] }, + { name: 'method with a separator character → Go 400', data: req([`Authorization: Bearer ${T}`], { method: 'G(ET' }), responses: [go400], audits: [] }, + { name: 'tab-separated request line → Go 400', data: Buffer.from(`GET\t${ENDPOINT}\tHTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [go400], audits: [] }, + { name: 'missing request target → Go 400', data: Buffer.from(`GET HTTP/1.1\r\nHost: x\r\n\r\n`), responses: [go400], audits: [] }, + { name: 'bare-LF line endings (tolerated by Go) → 200', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\nHost: x\nAuthorization: Bearer ${T}\nConnection: close\n\n`), responses: [ok], audits: [T] }, + { name: '64 KiB request target → 404', data: req([`Authorization: Bearer ${T}`], { target: '/' + 'z'.repeat(64 * 1024) }), responses: [{ status: 404 }], audits: [] }, + { name: 'UTF-8 and Latin-1 bytes in unrelated header values → 200', data: Buffer.concat([Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nX-U: \u{65e5}\u{672c}\r\nX-L: `), Buffer.from([0xe9, 0xff]), Buffer.from(`\r\nAuthorization: Bearer ${T}\r\nConnection: close\r\n\r\n`)]), responses: [ok], audits: [T] }, + ]; + const audited: string[] = []; + let appResponses = 0; + for (const c of cases) { + await test.step(c.name, async () => { + const r = await raw(http, server, c.data, { expectResponses: c.responses.length }); + expect(r.outcome, c.name).toBe('response'); + expect(r.responses.map((x) => x.status), c.name).toEqual(c.responses.map((x) => x.status)); + r.responses.forEach((res, i) => { + const e = c.responses[i]; + if (e.go) expect(res.header('content-type'), `${c.name}: Go plain-text response`).toBe('text/plain; charset=utf-8'); + else if (e.status === 200) expectContainersResponse(res); + else expectErrorResponse(res, e.status as 401 | 404 | 405); + if (!e.go) appResponses++; + expectNoTokenFragments(res.bodyText + JSON.stringify(res.headers), secrets, `${c.name} response`); + }); + audited.push(...c.audits); + }); + } + // the server survived everything + expectContainersResponse(await http.get(server.url(), { token: U })); + audited.push(U); + expect((await server.stop()).exitCode).toBe(0); + const logs = server.logs(); + expect(logs.requests().length, 'Go-rejected requests never reach the app log').toBe(appResponses + 1); + expect(readOperations(server.dbPath).map((o) => o.token), 'exactly the authenticated requests were audited, in order').toEqual(audited); + expect(server.stderr).not.toContain('QQQQQQQQQQQQQQQQ'); + expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0); + expectNoTokenFragments(server.stderr, secrets, 'server log'); + assertDbInvariants(server.dbPath, { tokenCount: 3, operationCount: audited.length }); +}); + +test('path normalization tricks and URL-encoding variations: only the exact endpoint after percent-decoding is served, nothing redirects', async ({ cli, startServer, http, secrets }) => { + const T = await cli.tokenNew('paths'); + const server = await startServer(); + server.fakeDocker!.many(1); + const targets: Array<[string, 200 | 404 | 400]> = [ + [ENDPOINT, 200], + ['/api/v1/%63ontainers', 200], + ['/%61%70%69/%76%31/%63%6f%6e%74%61%69%6e%65%72%73', 200], + ['/api/v1/containers?x=../../etc/passwd', 200], + ['/api/v1/containers?%00', 200], + ['/api/v1/containers?', 200], + ['/api/v1/containers?a=%zz', 200], + ['http://localhost/api/v1/containers', 200], + ['http://evil.example:1/api/v1/containers?x=1', 200], + ['/api/v1/containers/', 404], + ['/api/v1/containers/.', 404], + ['/api/v1/./containers', 404], + ['/api/v1/x/../containers', 404], + ['/api/v1/x/%2e%2e/containers', 404], + ['/api/v1/x/%2E%2E%2Fcontainers', 404], + ['/api/v1%2Fcontainers', 404], + ['/api%2Fv1%2Fcontainers', 404], + ['/api/v1/%2563ontainers', 404], + ['/api/v1/%c0%aecontainers', 404], + ['/api/v1/containers%00', 404], + ['/api/v1/containers%3F', 404], + ['/api/v1/containers%23', 404], + ['/api/v1/containers;jsessionid=1', 404], + ['/api/v1/containers..;/', 404], + ['/api/v1/containers#frag', 404], + ['//api/v1/containers', 404], + ['/api//v1/containers', 404], + ['/API/v1/containers', 404], + ['/api/v1/CONTAINERS', 404], + ['/api/v1/%43ontainers', 404], + ['/api/v1/\u{ff43}ontainers', 404], + ['/api/v1/%EF%BD%83ontainers', 404], + ['/api\\v1\\containers', 404], + ['/api/v1/containers\\', 404], + ['/api/v1/containers%20', 404], + ['/api/v1/containers.', 404], + ['*', 404], + ['/api/v1/%u0063ontainers', 400], + ['/api/v1/containers%', 400], + ['/api/v1/containers%zz', 400], + ]; + let audits = 0; + let reached = 0; + for (const [target, status] of targets) { + await test.step(`${target} → ${status} (and 405 / 401 variants)`, async () => { + if (target.startsWith('/')) expect(routeOf(target), 'the oracle agrees with the table').toBe(status === 200 ? 'endpoint' : status === 404 ? 'other' : 'bad'); + const variants: Array<[string, Buffer, number]> = [ + ['GET + token', req([`Authorization: Bearer ${T}`], { target }), status], + ['POST + token', req([`Authorization: Bearer ${T}`], { target, method: 'POST' }), status === 200 ? 405 : status], + ['GET, no token', req([], { target }), status === 200 ? 401 : status], + ]; + for (const [name, data, st] of variants) { + const res = onlyResponse(await raw(http, server, data), `${target} ${name}`); + expect(res.status, `${target} ${name}`).toBe(st); + expect(res.header('location'), 'never redirected').toBeUndefined(); + if (st === 400) expect(res.header('content-type')).toBe('text/plain; charset=utf-8'); + else { + reached++; + if (st === 200) expectContainersResponse(res); + else expectErrorResponse(res, st as 401 | 404 | 405); + } + if (st === 200) audits++; + expectNoTokenFragments(res.bodyText, secrets, 'response'); + } + // curl --path-as-is agrees for targets curl can carry verbatim + if (/^\/[A-Za-z0-9\-._~%/?=&;:@!$'()*+,]*$/.test(target) && !target.includes('%00') && status !== 400) { + const c = await http.curl({ url: server.url(target), token: T, pathAsIs: true }); + expect(c.exitCode).toBe(0); + expect(c.status, `curl ${target}`).toBe(status); + expect(c.headers['location']).toBeUndefined(); + reached++; + if (status === 200) audits++; + } + }); + } + expect((await server.stop()).exitCode).toBe(0); + expect(server.logs().requests().length).toBe(reached); + assertDbInvariants(server.dbPath, { tokenCount: 1, operationCount: audits }); +}); + +test('SQL injection in every user-controlled field (descriptions, delete argument, bearer token, query): schema and rows intact, payloads stored verbatim', async ({ cli, ws, startServer, http, secrets }) => { + test.setTimeout(90_000); + const model = new StateModel(); + const keep = model.add(await cli.tokenNew('keep'), 'keep').token; + const revoked = model.add(await cli.tokenNew('revoked'), 'revoked').token; + await cli.tokenDelete(revoked); + model.deactivate(revoked); + const attachTarget = ws.path('attached.db'); + const payloads = [...SQL_PAYLOADS, `'; ATTACH DATABASE '${attachTarget}' AS x; CREATE TABLE x.t(a); --`, `${keep}' OR '1'='1`, `${keep}'--`, `' OR token LIKE '%`, `x'); UPDATE tokens SET active=1 WHERE token='${revoked}'; --`]; + + await test.step('descriptions: stored byte-exact, listed Go-quoted, nothing executed', async () => { + for (const p of payloads) model.add(await cli.tokenNew(p), p); + model.verifyDb(ws.dbPath); + const rows = await cli.tokenList(); + model.verifyList(rows); + for (const p of payloads) expect(rows.some((r) => r.descriptionQuoted === goQuote(p)), `listed: ${p.slice(0, 40)}`).toBe(true); + }); + + await test.step('delete argument: every payload is "token not found", state byte-identical', async () => { + const before = stateDigest(ws.dbPath); + for (const p of payloads) { + const r = await cli.run(['token', 'delete', p]); + expectCliFailure(r, { exitCode: 1, message: 'token not found' }); + expect(r.stderr.includes(keep), 'token never echoed').toBe(false); + } + expect(stateDigest(ws.dbPath)).toBe(before); + }); + + const server = await startServer({ logLevel: 'DEBUG' }); + server.fakeDocker!.many(1); + await test.step('bearer token and query string: unknown token, never a match', async () => { + for (const p of payloads) { + const r = onlyResponse(await raw(http, server, req([`Authorization: Bearer ${p}`])), p); + expectErrorResponse(r, 401); + expectNoTokenFragments(r.bodyText, secrets, 'response'); + const q = await http.get(server.url(`${ENDPOINT}?token=${encodeURIComponent(p)}&filter=${encodeURIComponent(p)}`), { token: keep }); + expectContainersResponse(q); + model.audit(keep); + } + // the revoked token stays revoked despite the UPDATE payloads + expectErrorResponse(await http.get(server.url(), { token: revoked }), 401); + }); + expect((await server.stop()).exitCode).toBe(0); + const classes = server.logs().filter({ msg: 'authentication result' }).map((r) => r.attrs.result); + expect(classes.filter((c) => c === 'unknown_token')).toHaveLength(payloads.length); + expect(classes.filter((c) => c === 'inactive_token')).toHaveLength(1); + expect(fs.existsSync(attachTarget), 'ATTACH payload was never executed').toBe(false); + expect(readSchema(ws.dbPath).tables).toEqual(['operations', 'tokens']); + expect(readToken(ws.dbPath, revoked)!.active).toBe(0); + model.verifyDb(ws.dbPath, 'all payloads stored verbatim, no row altered'); +}); + +test('shell metacharacters, control characters, log/list forging and Unicode confusables in descriptions; NUL where the platform permits it', async ({ cli, ws, startServer, http, secrets, rng }) => { + test.setTimeout(90_000); + const model = new StateModel(); + const canary = ws.path('pwned'); + const shellish = [`$(touch ${canary})`, `\`touch ${canary}\``, `; touch ${canary}`, `| touch ${canary}`, `&& touch ${canary}`, `\ntouch ${canary}`, `'; touch ${canary}; '`, `"; touch ${canary}; "`, `\${IFS}touch\${IFS}${canary}`]; + const forging = [ + 'x\ntime=2026-01-01T00:00:00.000Z level=ERROR msg="forged record" origin=6.6.6.6', + '\r\nlevel=ERROR msg=forged', + '" level=ERROR msg="forged', + 'ok\x1b[2K\x1b[1Glevel=ERROR msg=forged\x1b[0m', + 'a\tfalse\t2020-01-01T00:00:00Z\t2020-01-02T00:00:00Z', + 'row\nFAKE.TOKEN\t""\ttrue\t2020-01-01T00:00:00Z\t-', + // forging the padded table: a closing quote and padded columns inside the description + 'a" false 2020-01-01T00:00:00Z 2020-01-02T00:00:00Z', + '"' + ' '.repeat(40) + 'true 2020-01-01T00:00:00Z -', + '\x08\x08\x08\x08\x7f\x07\x0c\x0b', + 'tab\there\u{2028}ls\u{2029}ps\u{85}nel', + ]; + const confusables = ['\u{430}dmin', 'admin', 'p\u{430}yp\u{430}l', '\u{202e}lanimret\u{202c}', '\u{ff21}\u{ff24}\u{ff2d}\u{ff29}\u{ff2e}', 'e\u{301}', '\u{e9}']; + + await test.step('created through argv and through a real shell with quoting: stored verbatim, never executed', async () => { + for (const d of [...shellish, ...forging, ...confusables]) model.add(await cli.tokenNew(d), d); + for (const d of shellish) { + const r = await cli.shell(`"$DPSAPI_BIN" token new ${shellQuote(d)}`); + expectCliSuccess(r, { stderr: '' }); + model.add(secrets.register(r.stdout.trim()), d); + } + expect(fs.existsSync(canary), 'no description was ever executed').toBe(false); + model.verifyDb(ws.dbPath); + }); + + await test.step('token list stays one strictly parseable row per token at INFO and at TRACE', async () => { + model.verifyList(await cli.tokenList()); + model.verifyList(await cli.tokenListAll(), 'token list all', 'all'); + // plain `token list` filters in SQL: one `row read` per active token only + const active = await cli.run(['token', 'list'], { env: { LOG_LEVEL: 'TRACE' } }); + expectCliSuccess(active); + model.verifyList(parseTokenList(active.stdout), 'token list at TRACE'); + expect(parseLogs(active.stderr).filter({ msg: 'row read' }), 'token list reads only the active rows').toHaveLength(model.active().length); + const r = await cli.run(['token', 'list', 'all'], { env: { LOG_LEVEL: 'TRACE' } }); + expectCliSuccess(r); + model.verifyList(parseTokenList(r.stdout), 'token list all at TRACE', 'all'); + const logs = parseLogs(r.stderr); + expect(logs.unparsed, 'every TRACE line is a single well-formed record').toEqual([]); + expect(logs.count({ level: 'ERROR' }), 'no forged ERROR record').toBe(0); + expect(logs.has({ msg: /forged/ })).toBe(false); + const read = logs.filter({ msg: 'row read' }); + expect(read).toHaveLength(model.tokens.size); + for (const d of forging) expect(read.some((x) => x.attrs.description === d), `description logged as one quoted attribute: ${JSON.stringify(d)}`).toBe(true); + }); + + await test.step('TRACE server: authenticating with every token logs its description safely', async () => { + const server = await startServer({ logLevel: 'TRACE' }); + for (const t of model.active()) { + expectContainersResponse(await http.get(server.url(), { token: t })); + model.audit(t); + } + expect((await server.stop()).exitCode).toBe(0); + const logs = server.logs(); + expect(logs.unparsed).toEqual([]); + expect(logs.count({ level: 'ERROR' })).toBe(0); + expect(logs.has({ msg: /forged/ })).toBe(false); + expect(logs.requests().every((x) => x.attrs.origin === '127.0.0.1')).toBe(true); + expect(fs.existsSync(canary)).toBe(false); + }); + + await test.step('Unicode confusables are distinct tokens/descriptions: confusable token variants never match', async () => { + const target = model.active()[0]; + const before = stateDigest(ws.dbPath); + for (const m of nearMatches(target, rng, 400).filter((x) => x.kind === 'confusable' || x.kind === 'zero-width').slice(0, 10)) { + expectCliFailure(await cli.run(['token', 'delete', m.value]), { exitCode: 1, message: 'token not found' }); + } + expect(stateDigest(ws.dbPath)).toBe(before); + const rows = await cli.tokenList(); + expect(rows.filter((r) => r.description === 'admin')).toHaveLength(1); + expect(rows.filter((r) => r.description === '\u{430}dmin')).toHaveLength(1); + }); + + await test.step('NUL: argv/env cannot carry it (platform), HTTP rejects or does not match it', async () => { + const before = stateDigest(ws.dbPath); + // execve(2) takes NUL-terminated strings: Node refuses to spawn, the binary never runs + await expect(cli.run(['token', 'new', 'a\x00b'])).rejects.toThrow(/harness:spawn|ERR_INVALID_ARG_VALUE|null bytes/); + await expect(cli.run(['token', 'list'], { env: { LOG_LEVEL: 'INFO\x00' } })).rejects.toThrow(/harness:spawn|ERR_INVALID_ARG_VALUE|null bytes/); + // a shell drops NUL from command substitution: the stored description has no NUL + const sh = await cli.shell(`"$DPSAPI_BIN" token new "$(printf 'a\\000b')"`); + expectCliSuccess(sh); + const tok = sh.stdout.trim(); + expect(readToken(ws.dbPath, tok)!.descriptionHex).toBe('6162'); + model.add(tok, 'ab'); + expect(stateDigest(ws.dbPath)).not.toBe(before); + const server = await startServer(); + const t = model.active()[0]; + expect(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}\x00`])), 'nul header').status).toBe(400); + expect(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t.slice(0, 20)}%00${t.slice(20)}`])), 'pct nul').status).toBe(401); + expectErrorResponse(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}`], { target: `${ENDPOINT}%00` })), 'nul path'), 404); + expectContainersResponse(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}`], { target: `${ENDPOINT}?a=%00` })), 'nul query')); + model.audit(t); + expect((await server.stop()).exitCode).toBe(0); + }); + model.verifyDb(ws.dbPath, 'final state'); +}); + +test('forwarding headers cannot spoof the audited origin (IPv4 and IPv6 listeners, 40 spoof variants each)', async ({ cli, ws, startServer, http, rng }) => { + const T = await cli.tokenNew('origin'); + const spoofs = ['X-Forwarded-For', 'Forwarded', 'X-Real-IP', 'True-Client-IP', 'CF-Connecting-IP', 'X-Client-IP', 'X-Originating-IP', 'X-Remote-Addr', 'X-Remote-IP', 'Client-IP', 'X-Cluster-Client-IP', 'Fastly-Client-IP', 'X-ProxyUser-Ip', 'Via']; + const values = ['6.6.6.6', '6.6.6.6, 127.0.0.1', 'for=6.6.6.6;proto=https', 'for="[2001:db8::6]:1"', 'unknown', '::1', '127.0.0.1', '10.0.0.1, 10.0.0.2, 10.0.0.3', '