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.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 0a6023e942
commit 581a672fcf
9 files changed
+1858

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
// 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 });
});