GET /api/v1/containers behind bearer token auth, JSON errors for 401/404/405/500, one audit row per authenticated request, graceful shutdown; unit and e2e tests for auth, routing, auditing, request logging and server lifecycle.
273 lines
15 KiB
TypeScript
273 lines
15 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §12 core item 9: wrong methods, unknown routes, trailing slashes, path tricks, query
|
|
* strings, fragments, repeated/unusual headers, empty and irrelevant bodies, and requests Go
|
|
* rejects before routing. Every request reaching the application must be logged exactly once
|
|
* at INFO; only authenticated GETs on the exact path are audited.
|
|
*/
|
|
import {
|
|
test,
|
|
expect,
|
|
assertDbInvariants,
|
|
buildRawRequest,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
readOperations,
|
|
type CliRunner,
|
|
type ServerHandle,
|
|
} from '../../harness';
|
|
|
|
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>) {
|
|
const token = await cli.tokenNew('surface');
|
|
const server = await startServer({ logLevel: 'INFO' });
|
|
server.fakeDocker!.many(1);
|
|
return { token, server };
|
|
}
|
|
|
|
test('every non-GET method on the endpoint → 405 with Allow: GET, before authentication', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
const seen: Array<[string, string]> = [];
|
|
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) {
|
|
for (const auth of [undefined, token, 'wrong']) {
|
|
await test.step(`${method} auth=${auth === token ? 'valid' : auth ?? 'none'}`, async () => {
|
|
expectErrorResponse(await http.fetch(server.url(), { method, token: auth }), 405);
|
|
seen.push([method, '405']);
|
|
});
|
|
}
|
|
}
|
|
// HEAD: same status/headers, no body
|
|
const head = await http.curl({ url: server.url(), head: true, token });
|
|
expect(head.exitCode).toBe(0);
|
|
expectErrorResponse({ status: head.status, headers: head.headers, body: head.body }, 405, { head: true });
|
|
seen.push(['HEAD', '405']);
|
|
// Methods curl/raw can send but browsers cannot
|
|
for (const method of ['PROPFIND', 'TRACE', 'FOO', 'get', 'Get', 'G-E_T']) {
|
|
await test.step(`curl -X ${method}`, async () => {
|
|
const r = await http.curl({ url: server.url(), method, token });
|
|
expectErrorResponse(r, 405);
|
|
seen.push([method, '405']);
|
|
});
|
|
}
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests().map((r) => [r.attrs.method, r.attrs.status])).toEqual(seen);
|
|
expect(server.logs().count({ msg: 'authentication result' })).toBe(0);
|
|
expect(readOperations(server.dbPath)).toEqual([]);
|
|
});
|
|
|
|
test('unknown routes, trailing slashes, case and path tricks → 404 (no redirects, no cleaning)', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
const pw = [
|
|
'/',
|
|
'/api',
|
|
'/api/',
|
|
'/api/v1',
|
|
'/api/v1/',
|
|
'/api/v1/containers/',
|
|
'/api/v1/containers//',
|
|
'/api/v1/containers/x',
|
|
'/api/v1/containers.json',
|
|
'/api/v1/container',
|
|
'/API/v1/containers',
|
|
'/api/V1/containers',
|
|
'/api/v1/Containers',
|
|
'/api/v2/containers',
|
|
'/api/v1/containers%2F',
|
|
'/api/v1/containers%3Fx=1',
|
|
'/api/v1/containers%00',
|
|
'/api/v1/containers;jsessionid=1',
|
|
'/favicon.ico',
|
|
'/.env',
|
|
];
|
|
const logged: string[] = [];
|
|
for (const p of pw) {
|
|
await test.step(`GET ${p}`, async () => {
|
|
expectErrorResponse(await http.get(server.url(p), { token }), 404);
|
|
});
|
|
logged.push(p);
|
|
}
|
|
// Normalizing clients would rewrite these; send them verbatim
|
|
for (const p of ['//api/v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/./api/v1/containers', '/api//v1/containers', '/api/v1/containers/.', '/api/v1/containers/..']) {
|
|
await test.step(`curl --path-as-is ${p}`, async () => {
|
|
const r = await http.curl({ url: server.url(p), token, pathAsIs: true });
|
|
expectErrorResponse(r, 404);
|
|
});
|
|
logged.push(p);
|
|
}
|
|
// other methods on unknown routes are 404, not 405
|
|
expectErrorResponse(await http.fetch(server.url('/api/v1/containers/'), { method: 'POST', token }), 404);
|
|
expectErrorResponse(await http.fetch(server.url('/nope'), { method: 'DELETE' }), 404);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const reqs = server.logs().requests();
|
|
expect(reqs.slice(0, logged.length).map((r) => [r.attrs.path, r.attrs.status])).toEqual(
|
|
logged.map((p) => [decodeURIComponent(p), '404']), // the app logs the decoded URL path
|
|
);
|
|
expect(readOperations(server.dbPath)).toEqual([]);
|
|
});
|
|
|
|
test('percent-encoded slashes are not path separators', async ({ cli, startServer, http }) => {
|
|
// %2F is not equivalent to "/", so these are unknown routes even with a valid token.
|
|
const { token, server } = await setup(cli, startServer);
|
|
for (const p of ['/api/v1%2Fcontainers', '/api%2Fv1%2Fcontainers', '/api%2fv1%2fcontainers']) {
|
|
expectErrorResponse(await http.curl({ url: server.url(p), token, pathAsIs: true }), 404);
|
|
expectErrorResponse(await http.curl({ url: server.url(p), pathAsIs: true }), 404);
|
|
}
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(6).fill('404'));
|
|
expect(readOperations(server.dbPath)).toEqual([]);
|
|
});
|
|
|
|
test('query strings, percent-encoded unreserved characters and fragments', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
let audited = 0;
|
|
for (const p of ['/api/v1/containers?x=1', '/api/v1/containers?', '/api/v1/containers?a=b&a=c', '/api/v1/containers?all=1&filters=%7B%7D', '/api/v1/containers?%00=%ff', '/api/v1/%63ontainers', '/%61pi/v1/containers']) {
|
|
await test.step(`GET ${p} → endpoint`, async () => {
|
|
expectContainersResponse(await http.get(server.url(p), { token }));
|
|
audited++;
|
|
});
|
|
}
|
|
// the app never forwards client query parameters to Docker (always running containers only)
|
|
for (const q of server.fakeDocker!.requests.filter((r) => r.path === '/containers/json')) expect(q.query).not.toMatch(/all|filters/);
|
|
|
|
// curl strips the fragment client-side: endpoint reached
|
|
expectContainersResponse(await http.curl({ url: server.url('/api/v1/containers#frag'), token }));
|
|
audited++;
|
|
// a fragment sent on the wire is part of the request target → not the endpoint
|
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers#frag', headers: [`Authorization: Bearer ${token}`] }) });
|
|
expectErrorResponse(r.response!, 404);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests().at(-1)!.attrs.path).toBe('/api/v1/containers#frag');
|
|
expect(readOperations(server.dbPath)).toHaveLength(audited);
|
|
assertDbInvariants(server.dbPath, { operationCount: audited });
|
|
});
|
|
|
|
test('repeated, unusual and irrelevant headers do not change a valid request', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
const auth = `Authorization: Bearer ${token}`;
|
|
const curlCases: Array<[string, string[]]> = [
|
|
['repeated Accept', ['Accept: text/html', 'Accept: application/xml']],
|
|
['Accept that excludes JSON', ['Accept: image/png']],
|
|
['repeated custom header', ['X-Thing: 1', 'X-Thing: 2', 'X-Thing: 3']],
|
|
['odd but valid Host', ['Host: evil.example:9999']],
|
|
['User-Agent removed', ['User-Agent:']],
|
|
['Accept-Encoding gzip', ['Accept-Encoding: gzip, br']],
|
|
['Cookie and Referer', ['Cookie: session=abc; other=1', 'Referer: http://elsewhere/']],
|
|
['Content-Type on a GET', ['Content-Type: application/x-www-form-urlencoded']],
|
|
['many headers (100)', Array.from({ length: 100 }, (_, i) => `X-Filler-${i}: ${'v'.repeat(20)}`)],
|
|
['one large header (32 KiB)', [`X-Large: ${'z'.repeat(32 * 1024)}`]],
|
|
['Connection: keep-alive', ['Connection: keep-alive']],
|
|
['Cache-Control/Pragma', ['Cache-Control: no-cache', 'Pragma: no-cache']],
|
|
['If-None-Match', ['If-None-Match: "abc"', 'If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT']],
|
|
['Range', ['Range: bytes=0-1']],
|
|
];
|
|
let n = 0;
|
|
for (const [name, headers] of curlCases) {
|
|
await test.step(name, async () => {
|
|
const r = await http.curl({ url: server.url(), headers: [auth, ...headers] });
|
|
expect(r.exitCode).toBe(0);
|
|
expectContainersResponse(r);
|
|
expect(r.headers['content-encoding']).toBeUndefined();
|
|
n++;
|
|
});
|
|
}
|
|
await test.step('HTTP/1.0', async () => {
|
|
const r = await http.curl({ url: server.url(), token, httpVersion: '1.0' });
|
|
expectContainersResponse(r);
|
|
n++;
|
|
});
|
|
await test.step('Playwright: header names in odd case and many custom headers', async () => {
|
|
const headers: Record<string, string> = { aUtHoRiZaTiOn: `Bearer ${token}`, 'x-ünused': 'x', 'X-Empty': '' };
|
|
delete headers['x-ünused'];
|
|
expectContainersResponse(await http.get(server.url(), { headers }));
|
|
n++;
|
|
});
|
|
await test.step('pipelined requests on one connection', async () => {
|
|
const one = buildRawRequest({ target: '/api/v1/containers', headers: [auth], close: false });
|
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: one + one + buildRawRequest({ target: '/api/v1/containers', headers: [auth] }), expectResponses: 3 });
|
|
expect(r.responses).toHaveLength(3);
|
|
for (const resp of r.responses) expectContainersResponse(resp);
|
|
n += 3;
|
|
});
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(n).fill('200'));
|
|
expect(readOperations(server.dbPath)).toHaveLength(n);
|
|
assertDbInvariants(server.dbPath, { operationCount: n });
|
|
});
|
|
|
|
test('empty and irrelevant request bodies', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
// GET with bodies: ignored
|
|
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: '{"filters":{"status":["exited"]},"all":true}', headers: { 'Content-Type': 'application/json' } }));
|
|
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: Buffer.alloc(64 * 1024, 0x41) }));
|
|
const zero = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer ${token}`, 'Content-Length: 0'] }) });
|
|
expectContainersResponse(zero.response!);
|
|
const chunked = await http.raw({
|
|
host: server.connectHost,
|
|
port: server.port,
|
|
data: `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${token}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n`,
|
|
});
|
|
expectContainersResponse(chunked.response!);
|
|
// non-GET with or without bodies: 405
|
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token }), 405);
|
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token, data: '' }), 405);
|
|
expectErrorResponse(await http.fetch(server.url(), { method: 'PUT', token, data: 'a=1&b=2', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 405);
|
|
expectErrorResponse(await http.curl({ url: server.url(), method: 'DELETE', token, data: '{"id":"x"}' }), 405);
|
|
expectErrorResponse(await http.curl({ url: server.url(), token, data: 'payload' }), 405); // curl --data-binary defaults to POST
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(readOperations(server.dbPath)).toHaveLength(4);
|
|
});
|
|
|
|
test('requests rejected by Go before routing get plain-text errors, are not logged or audited, and the server survives', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
const raw = (data: string | Buffer, extra: object = {}) => http.raw({ host: server.connectHost, port: server.port, data, ...extra });
|
|
const cases: Array<[string, string | Buffer, number]> = [
|
|
['garbage request line', 'GARBAGE\r\n\r\n', 400],
|
|
['missing HTTP version', 'GET /api/v1/containers\r\n\r\n', 400],
|
|
['unsupported HTTP version', 'GET /api/v1/containers HTTP/9.9\r\nHost: x\r\n\r\n', 505],
|
|
['HTTP/1.1 without Host', 'GET /api/v1/containers HTTP/1.1\r\nConnection: close\r\n\r\n', 400],
|
|
['duplicate Host', 'GET /api/v1/containers HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n', 400],
|
|
['space before colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization : Bearer x\r\n\r\n', 400],
|
|
['header line without colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nNoColonHere\r\n\r\n', 400],
|
|
['invalid header name', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nBad(Name): 1\r\n\r\n', 400],
|
|
['conflicting Content-Length', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n', 400],
|
|
['request target not a path', 'GET api/v1/containers HTTP/1.1\r\nHost: x\r\n\r\n', 400],
|
|
// Go's limit is DefaultMaxHeaderBytes (1 MiB) plus 4 KiB of slack
|
|
['headers of 2 MiB', `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nX-Big: ${'a'.repeat(2 * 1024 * 1024)}\r\n\r\n`, 431],
|
|
];
|
|
for (const [name, data, status] of cases) {
|
|
await test.step(name, async () => {
|
|
const r = await raw(data);
|
|
expect(r.outcome, name).toBe('response');
|
|
expect(r.response!.status, name).toBe(status);
|
|
expect(r.response!.header('content-type') ?? 'text/plain; charset=utf-8').toMatch(/^text\/plain/);
|
|
});
|
|
}
|
|
// obs-fold continuation lines are accepted and unfolded by Go (RFC 9112 §5.2 allows replacing with SP)
|
|
const folded = await raw(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer\r\n ${token}\r\nConnection: close\r\n\r\n`);
|
|
expectContainersResponse(folded.response!);
|
|
const opts = await raw('OPTIONS * HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n');
|
|
expect(opts.response!.status).toBe(200);
|
|
// the server keeps serving
|
|
expectContainersResponse(await http.get(server.url(), { token }));
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const logs = server.logs();
|
|
expect(logs.requests().map((r) => r.attrs.status), 'only the two valid requests were logged').toEqual(['200', '200']);
|
|
expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
|
expect(readOperations(server.dbPath)).toHaveLength(2);
|
|
});
|
|
|
|
test('half-open and abruptly closed client connections do not break the server', async ({ cli, startServer, http }) => {
|
|
const { token, server } = await setup(cli, startServer);
|
|
// client sends nothing and closes
|
|
const a = await http.raw({ host: server.connectHost, port: server.port, data: '', endAfterWrite: true, timeoutMs: 3000 });
|
|
expect(['closed', 'reset']).toContain(a.outcome);
|
|
// client sends half a request and closes
|
|
const b = await http.raw({ host: server.connectHost, port: server.port, data: 'GET /api/v1/contai', endAfterWrite: true, timeoutMs: 3000 });
|
|
expect(['closed', 'reset', 'response']).toContain(b.outcome);
|
|
if (b.outcome === 'response') expect(b.response!.status).toBe(400);
|
|
expectContainersResponse(await http.get(server.url(), { token }));
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests()).toHaveLength(1);
|
|
});
|