Files
dpsapi/e2e/tests/image/image-abuse.spec.ts
bryanpedini 581a672fcf feat(image): add scratch container image
Two-stage build producing a static /app on scratch; e2e tests for image contents, server and CLI through docker exec, socket handling, lifecycle and host/image parity.
2026-10-06 10:52:25 +02:00

185 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Compact malicious/fuzz pass against the final image (PLAN §12 malicious/fuzzer personas, image
* side): SQL-injection-like and shell-metacharacter descriptions and token arguments through
* `docker exec` (no shell exists in the image, so nothing can be interpreted — `docker diff`
* proves no file was created), a long description, deterministic random descriptions, hostile
* Authorization/forwarding headers and path tricks from outside the container, then invariant
* checks on a DB snapshot and normal operations.
*/
import {
test,
expect,
assertDbInvariants,
buildRawRequest,
dockerCli,
expectCliFailure,
expectCliSuccess,
expectContainersResponse,
expectErrorResponse,
expectNoInternalDetails,
goQuote,
parseLogs,
parseTokenList,
readOperations,
readTokens,
} from '../../harness';
import { execApp, expectedOrigins, hex } from './image-helpers';
test('abuse through docker exec and HTTP from outside: verbatim storage, no shell, no bypass, invariants hold, normal operation continues', async ({ image, http, fuzz, secrets }) => {
test.setTimeout(120_000);
const c = await image.start({ suffix: 'abuse' });
c.fakeDocker!.many(2);
const victim = (await c.exec(['token', 'new', 'victim'])).stdout.trim();
// ---- CLI: hostile descriptions ----
const hostile: string[] = [
`'); DROP TABLE tokens; --`,
`x' OR '1'='1`,
`"; DELETE FROM operations; UPDATE tokens SET active=1; --`,
`$(touch /pwned)`,
'`touch /pwned2`',
'; rm -rf / ; echo owned > /app',
'| cat /etc/passwd > /data/leak',
'&& /bin/sh -c id',
'%s%n%x %!v',
'\x1b[31mred\x1b[0m\r\x07',
'Веаrеr confusable zero‑width​',
'x'.repeat(100_000),
];
// deterministic random descriptions (printable ASCII + some Unicode; no NUL — not representable in argv)
const alphabet = [...`abcXYZ019 '"\`$;|&<>(){}[]*?~!#%\\/\t\n`, 'é', '☃', '日', '🚀', '​'];
for (let i = 0; i < 6; i++) {
const len = fuzz.rng.int(0, 64);
hostile.push(fuzz.record({ kind: 'description', i, value: Array.from({ length: len }, () => fuzz.rng.pick(alphabet)).join('') }).value);
}
const stored = new Map<string, string>([[victim, 'victim']]);
for (const [i, d] of hostile.entries()) {
await test.step(`token new hostile #${i}`, async () => {
const r = await c.exec(['token', 'new', d]);
expectCliSuccess(r, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
stored.set(r.stdout.trim(), d);
});
}
// ---- CLI: hostile token arguments ----
const flip = (s: string) => s.replace(/[a-z]/, (ch) => ch.toUpperCase());
const badDeletes: Array<[string, string]> = [
[`' OR '1'='1`, 'token not found'],
['%', 'token not found'],
['*', 'token not found'],
[`${victim}' --`, 'token not found'],
[`${victim} `, 'token not found'],
[`\n${victim}`, 'token not found'],
[flip(victim), 'token not found'],
[victim.slice(0, 42), 'token not found'],
['', 'token not found'],
['$(id)', 'token not found'],
];
for (const [arg, message] of badDeletes) {
await test.step(`token delete ${JSON.stringify(arg.slice(0, 12))}…`, async () => {
const r = await execApp(c, ['token', 'delete', arg]);
expectCliFailure(r, { exitCode: 1, message });
if (arg) expect(r.stderr.includes(arg), 'argument never echoed').toBe(false);
});
}
expectCliFailure(await execApp(c, ['token', 'delete', victim, '; rm -rf /']), { exitCode: 2, message: 'usage error: token delete requires exactly one argument' });
expectCliFailure(await execApp(c, ['token', 'new', 'a', '$(id)']), { exitCode: 2, message: 'usage error: token new takes at most one argument' });
// nothing outside the mounts changed in the container filesystem: no /pwned, /app untouched
const diff = await dockerCli(['diff', c.name]);
// As observed: Docker reports the mount points it created itself (/data, /var/run/docker.sock)
expect(diff.stdout.trim().split('\n').sort(), 'docker diff').toEqual(['A /data', 'A /var', 'A /var/run', 'A /var/run/docker.sock']);
// ---- HTTP from outside: hostile authorization and paths ----
let authenticated = 0;
await test.step('hostile Authorization values → 401, no details', async () => {
const values = [
`Bearer ' OR '1'='1`,
`Bearer ${victim}' OR '1'='1`,
'Bearer %',
'Bearer *',
`Bearer ${flip(victim)}`,
`Bearer ${victim.slice(0, 42)}`,
`Bearer ${victim}x`,
`Bearer ${victim}`,
`Bearer ${victim}, Bearer ${victim}`,
`Bearer\t${victim}`,
`Token ${victim}`,
'Bearer ' + 'A'.repeat(8192),
];
for (const v of values) {
const r = await http.curl({ url: c.url(), headers: [`Authorization: ${v}`] });
expect(r.exitCode).toBe(0);
expectErrorResponse(r, 401);
expectNoInternalDetails(r.body);
}
// duplicated header and CRLF header-injection attempt over a raw socket
const { hostname, port } = new URL(c.baseURL);
const dup = await http.raw({ host: hostname, port: Number(port), data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer ${victim}`, `Authorization: Bearer ${victim}`] }) });
expectErrorResponse(dup.response!, 401);
const inj = await http.raw({ host: hostname, port: Number(port), data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer x\r\nAuthorization: Bearer ${victim}`] }) });
expectErrorResponse(inj.response!, 401);
});
await test.step('forwarding headers cannot spoof the audited origin; path tricks', async () => {
const r = await http.curl({ url: c.url(), token: victim, headers: ['X-Forwarded-For: 203.0.113.9', 'Forwarded: for=203.0.113.9', 'X-Real-IP: 203.0.113.9'] });
expectContainersResponse(r);
authenticated++;
const enc = await http.curl({ url: c.url('/api/v1/%63ontainers?x=%27--'), token: victim });
expectContainersResponse(enc);
authenticated++;
for (const p of ['/api/v1/containers/', '//api/v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/api/v1%2Fcontainers', '/API/V1/CONTAINERS']) {
const x = await http.curl({ url: c.url(p), token: victim, pathAsIs: true });
expectErrorResponse(x, 404);
}
expectErrorResponse(await http.curl({ url: c.url(), token: victim, method: 'DELETE' }), 405);
});
const origins = await expectedOrigins(c);
// ---- invariants on a snapshot ----
await test.step('database invariants after the abuse', async () => {
const snap = await c.snapshotDb();
const rows = readTokens(snap);
expect(rows.length).toBe(stored.size);
for (const r of rows) expect(r.descriptionHex, 'stored verbatim').toBe(hex(stored.get(r.token)!));
expect(rows.every((r) => r.active === 1), 'no hostile input deactivated (or reactivated) anything').toBe(true);
const ops = readOperations(snap);
expect(ops).toHaveLength(authenticated);
for (const o of ops) {
expect(o.token).toBe(victim);
expect(origins, 'origin is the TCP peer, never 203.0.113.9').toContain(o.origin);
}
assertDbInvariants(snap, { tokenCount: stored.size, operationCount: authenticated });
// token list stays one strictly parseable line per token whatever the descriptions contain
const list = await c.exec(['token', 'list']);
expectCliSuccess(list, { stderr: '' });
const parsed = parseTokenList(list.stdout);
expect(parsed).toHaveLength(stored.size);
for (const p of parsed) expect(p.descriptionQuoted).toBe(goQuote(stored.get(p.token)!));
});
// ---- normal operation continues ----
await test.step('normal administration afterwards', async () => {
const fresh = (await c.exec(['token', 'new', 'after abuse'])).stdout.trim();
expect(secrets.has(fresh)).toBe(true);
expect(expectContainersResponse(await http.get(c.url(), { token: fresh }))).toHaveLength(2);
authenticated++;
expectCliSuccess(await c.exec(['token', 'delete', victim]), { stdout: '', stderr: '' });
expectErrorResponse(await http.get(c.url(), { token: victim }), 401);
expectContainersResponse(await http.get(c.url(), { token: fresh }));
authenticated++;
});
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const logs = parseLogs(exit.stderr);
expect(logs.unparsed).toEqual([]);
expect(logs.count({ level: 'ERROR' })).toBe(0);
secrets.assertNoSecretsIn(exit.stderr, 'docker logs');
const snap = await c.snapshotDb();
assertDbInvariants(snap, { tokenCount: stored.size + 1, operationCount: authenticated, auditBeforeDeletion: true });
});