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:
1 parent
0a6023e942
commit
581a672fcf
9 files changed
+1858
No files matched your search
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user