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.
254 lines
13 KiB
TypeScript
254 lines
13 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||
|
||
/**
|
||
* PLAN §12 core items 8 and 10: the Authorization matrix (missing, empty, wrong scheme, malformed,
|
||
* unknown, near-match, inactive, duplicated, unusual) with exact status, JSON body and headers,
|
||
* the authentication result class logged at DEBUG, and audit rows only for successes.
|
||
*/
|
||
import {
|
||
test,
|
||
expect,
|
||
assertDbInvariants,
|
||
buildRawRequest,
|
||
expectContainersResponse,
|
||
expectErrorResponse,
|
||
readOperations,
|
||
type CliRunner,
|
||
type HttpClient,
|
||
type RawResult,
|
||
type ServerHandle,
|
||
} from '../../harness';
|
||
|
||
interface Ctx {
|
||
server: ServerHandle;
|
||
active: string;
|
||
other: string;
|
||
inactive: string;
|
||
}
|
||
|
||
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>): Promise<Ctx> {
|
||
const active = await cli.tokenNew('active');
|
||
const other = await cli.tokenNew('other');
|
||
const inactive = await cli.tokenNew('inactive');
|
||
await cli.tokenDelete(inactive);
|
||
const server = await startServer({ logLevel: 'DEBUG' });
|
||
server.fakeDocker!.many(1);
|
||
return { server, active, other, inactive };
|
||
}
|
||
|
||
/** Raw request with verbatim header lines. */
|
||
function raw(http: HttpClient, s: ServerHandle, headerLines: Array<string | Buffer>, target = '/api/v1/containers'): Promise<RawResult> {
|
||
const head = Buffer.concat([
|
||
Buffer.from(`GET ${target} HTTP/1.1\r\nHost: localhost\r\n`, 'latin1'),
|
||
...headerLines.map((h) => Buffer.concat([typeof h === 'string' ? Buffer.from(h, 'latin1') : h, Buffer.from('\r\n')])),
|
||
Buffer.from('Connection: close\r\n\r\n'),
|
||
]);
|
||
return http.raw({ host: s.connectHost, port: s.port, data: head });
|
||
}
|
||
|
||
function authClasses(s: ServerHandle): string[] {
|
||
return s.logs().filter({ msg: 'authentication result' }).map((r) => r.attrs.result);
|
||
}
|
||
|
||
const flipCase = (t: string) => [...t].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join('');
|
||
|
||
test('missing, empty and malformed Authorization headers → 401 (Playwright request)', async ({ cli, startServer, http }) => {
|
||
const { server, active } = await setup(cli, startServer);
|
||
const cases: Array<[string, Record<string, string>, string]> = [
|
||
['no header', {}, 'missing_header'],
|
||
['scheme only', { Authorization: 'Bearer' }, 'empty_token'],
|
||
['scheme + trailing space (trimmed by HTTP)', { Authorization: 'Bearer ' }, 'empty_token'],
|
||
['Basic credentials', { Authorization: 'Basic dXNlcjpwYXNz' }, 'bad_scheme'],
|
||
['Token scheme', { Authorization: `Token ${active}` }, 'bad_scheme'],
|
||
['bare token, no scheme', { Authorization: active }, 'bad_scheme'],
|
||
['tab instead of space', { Authorization: `Bearer\t${active}` }, 'bad_scheme'],
|
||
['scheme with colon', { Authorization: `Bearer: ${active}` }, 'bad_scheme'],
|
||
['misspelled scheme', { Authorization: `Baerer ${active}` }, 'bad_scheme'],
|
||
['scheme prefix', { Authorization: `BearerX ${active}` }, 'bad_scheme'],
|
||
['double space before token', { Authorization: `Bearer ${active}` }, 'unknown_token'],
|
||
['token followed by extra word', { Authorization: `Bearer ${active} extra` }, 'unknown_token'],
|
||
['quoted token', { Authorization: `Bearer "${active}"` }, 'unknown_token'],
|
||
['two credentials in one value', { Authorization: `Bearer ${active}, Bearer ${active}` }, 'unknown_token'],
|
||
['Bearer Bearer', { Authorization: `Bearer Bearer ${active}` }, 'unknown_token'],
|
||
['different header (Proxy-Authorization)', { 'Proxy-Authorization': `Bearer ${active}` }, 'missing_header'],
|
||
['token in X-Auth-Token', { 'X-Auth-Token': active }, 'missing_header'],
|
||
];
|
||
for (const [name, headers] of cases) {
|
||
await test.step(name, async () => {
|
||
expectErrorResponse(await http.get(server.url(), { headers }), 401);
|
||
});
|
||
}
|
||
// query-string tokens are not credentials
|
||
expectErrorResponse(await http.get(server.url(`/api/v1/containers?access_token=${active}`)), 401);
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual([...cases.map((c) => c[2]), 'missing_header']);
|
||
expect(readOperations(server.dbPath)).toEqual([]);
|
||
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||
});
|
||
|
||
test('empty and whitespace-only header values → 401 (curl and raw socket)', async ({ cli, startServer, http }) => {
|
||
const { server } = await setup(cli, startServer);
|
||
// curl: "Name;" sends an empty header
|
||
expectErrorResponse(await http.curl({ url: server.url(), headers: ['Authorization;'] }), 401);
|
||
const cases: Array<[string, string, string]> = [
|
||
['empty value', 'Authorization:', 'empty_header'],
|
||
['spaces only', 'Authorization: ', 'empty_header'],
|
||
['tab only', 'Authorization:\t', 'empty_header'],
|
||
['no space after colon, Bearer only', 'Authorization:Bearer', 'empty_token'],
|
||
['Bearer + spaces', 'Authorization: Bearer ', 'empty_token'],
|
||
];
|
||
for (const [name, line] of cases) {
|
||
await test.step(name, async () => {
|
||
const r = await raw(http, server, [line]);
|
||
expect(r.outcome).toBe('response');
|
||
expectErrorResponse(r.response!, 401);
|
||
});
|
||
}
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual(['empty_header', ...cases.map((c) => c[2])]);
|
||
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||
});
|
||
|
||
test('unknown, near-match and inactive tokens → 401', async ({ cli, startServer, http, rng }) => {
|
||
const { server, active, inactive } = await setup(cli, startServer);
|
||
const lastFlip = active.slice(0, -1) + (active.endsWith('A') ? 'B' : 'A');
|
||
const cases: Array<[string, string, string]> = [
|
||
['random unknown token', rng.string(43, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'), 'unknown_token'],
|
||
['last character changed', lastFlip, 'unknown_token'],
|
||
['case swapped', flipCase(active), 'unknown_token'],
|
||
['one character short', active.slice(0, 42), 'unknown_token'],
|
||
['one character extra', active + 'A', 'unknown_token'],
|
||
['base64 std alphabet variant', active.replaceAll('-', '+').replaceAll('_', '/'), active.includes('-') || active.includes('_') ? 'unknown_token' : 'ok'],
|
||
['padded with =', active + '=', 'unknown_token'],
|
||
['percent-encoded first char', '%' + active.charCodeAt(0).toString(16) + active.slice(1), 'unknown_token'],
|
||
['SQL injection', `' OR '1'='1`, 'unknown_token'],
|
||
['SQL wildcard', '%', 'unknown_token'],
|
||
['inactive (deleted) token', inactive, 'inactive_token'],
|
||
['inactive token, case swapped', flipCase(inactive), 'unknown_token'],
|
||
];
|
||
let oks = 0;
|
||
for (const [name, token, cls] of cases) {
|
||
await test.step(name, async () => {
|
||
const r = await http.get(server.url(), { token });
|
||
if (cls === 'ok') {
|
||
expectContainersResponse(r);
|
||
oks++;
|
||
} else expectErrorResponse(r, 401);
|
||
});
|
||
}
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||
expect(readOperations(server.dbPath)).toHaveLength(oks);
|
||
assertDbInvariants(server.dbPath);
|
||
});
|
||
|
||
test('accepted forms: case-insensitive scheme, header-name case, surrounding whitespace → 200 and audited', async ({ cli, startServer, http }) => {
|
||
const { server, active, other } = await setup(cli, startServer);
|
||
const pw: Array<[string, Record<string, string>, string]> = [
|
||
['canonical', { Authorization: `Bearer ${active}` }, active],
|
||
['lower-case scheme', { Authorization: `bearer ${active}` }, active],
|
||
['upper-case scheme', { Authorization: `BEARER ${other}` }, other],
|
||
['mixed-case scheme', { Authorization: `bEaReR ${other}` }, other],
|
||
['lower-case header name', { authorization: `Bearer ${active}` }, active],
|
||
];
|
||
const audited: string[] = [];
|
||
for (const [name, headers, tok] of pw) {
|
||
await test.step(name, async () => {
|
||
expectContainersResponse(await http.get(server.url(), { headers }));
|
||
audited.push(tok);
|
||
});
|
||
}
|
||
const rawCases: Array<[string, string, string]> = [
|
||
['trailing spaces trimmed by HTTP parsing', `Authorization: Bearer ${active} `, active],
|
||
['trailing tab trimmed', `Authorization: Bearer ${active}\t`, active],
|
||
['leading whitespace before scheme trimmed', `Authorization: Bearer ${other}`, other],
|
||
['UPPER-CASE header name', `AUTHORIZATION: Bearer ${other}`, other],
|
||
['no space after colon', `Authorization:Bearer ${active}`, active],
|
||
];
|
||
for (const [name, line, tok] of rawCases) {
|
||
await test.step(name, async () => {
|
||
const r = await raw(http, server, [line]);
|
||
expectContainersResponse(r.response!);
|
||
audited.push(tok);
|
||
});
|
||
}
|
||
// curl
|
||
expectContainersResponse(await http.curl({ url: server.url(), token: active }));
|
||
audited.push(active);
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual(audited.map(() => 'ok'));
|
||
expect(readOperations(server.dbPath).map((o) => o.token)).toEqual(audited);
|
||
assertDbInvariants(server.dbPath, { operationCount: audited.length });
|
||
});
|
||
|
||
test('duplicated and conflicting Authorization headers → 401 (multiple_headers), never audited', async ({ cli, startServer, http }) => {
|
||
const { server, active, other, inactive } = await setup(cli, startServer);
|
||
const curlCases: Array<[string, string[]]> = [
|
||
['same valid header twice', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${active}`]],
|
||
['two different valid tokens', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${other}`]],
|
||
['valid + invalid', [`Authorization: Bearer ${active}`, 'Authorization: Basic Zm9vOmJhcg==']],
|
||
['invalid + valid', ['Authorization: Bearer nope', `Authorization: Bearer ${active}`]],
|
||
['valid + empty', [`Authorization: Bearer ${active}`, 'Authorization;']],
|
||
['valid + inactive', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${inactive}`]],
|
||
];
|
||
for (const [name, headers] of curlCases) {
|
||
await test.step(`curl: ${name}`, async () => {
|
||
const r = await http.curl({ url: server.url(), headers });
|
||
expect(r.exitCode).toBe(0);
|
||
expectErrorResponse(r, 401);
|
||
});
|
||
}
|
||
await test.step('raw: three headers with different name case', async () => {
|
||
const r = await raw(http, server, [`authorization: Bearer ${active}`, `Authorization: Bearer ${active}`, `AUTHORIZATION: Bearer ${active}`]);
|
||
expectErrorResponse(r.response!, 401);
|
||
});
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual([...curlCases.map(() => 'multiple_headers'), 'multiple_headers']);
|
||
expect(readOperations(server.dbPath)).toEqual([]);
|
||
});
|
||
|
||
test('unusual but syntactically valid Authorization values → 401 without leaking anything', async ({ cli, startServer, http }) => {
|
||
const { server, active } = await setup(cli, startServer);
|
||
const cases: Array<[string, Buffer | string, string]> = [
|
||
['very long token (16 KiB)', `Authorization: Bearer ${'A'.repeat(16 * 1024)}`, 'unknown_token'],
|
||
['UTF-8 bytes in token (obs-text)', Buffer.concat([Buffer.from('Authorization: Bearer '), Buffer.from('tökén-ü', 'utf8')]), 'unknown_token'],
|
||
['Cyrillic look-alike scheme', Buffer.concat([Buffer.from('Authorization: B'), Buffer.from('е', 'utf8'), Buffer.from(`arer ${active}`)]), 'bad_scheme'],
|
||
['Latin-1 NBSP instead of space', Buffer.concat([Buffer.from('Authorization: Bearer\xa0', 'latin1'), Buffer.from(active)]), 'bad_scheme'],
|
||
['token followed by more words', `Authorization: Bearer ${active} x`, 'unknown_token'],
|
||
];
|
||
for (const [name, line] of cases) {
|
||
await test.step(name, async () => {
|
||
const r = await raw(http, server, [line]);
|
||
expect(r.outcome).toBe('response');
|
||
expectErrorResponse(r.response!, 401);
|
||
});
|
||
}
|
||
// Control characters (other than HTAB) are invalid in field values: Go rejects the request
|
||
// with a plain-text 400 before the application sees it (not logged, not audited).
|
||
for (const ctl of ['\x7f', '\x01', '\x1b']) {
|
||
await test.step(`control character ${JSON.stringify(ctl)} in the token → Go 400`, async () => {
|
||
const r = await raw(http, server, [`Authorization: Bearer ${active.slice(0, 20)}${ctl}${active.slice(21)}`]);
|
||
expect(r.response!.status).toBe(400);
|
||
expect(r.response!.header('content-type')).toBe('text/plain; charset=utf-8');
|
||
});
|
||
}
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||
expect(server.logs().requests(), 'Go-rejected requests are not logged').toHaveLength(cases.length);
|
||
expect(readOperations(server.dbPath)).toEqual([]);
|
||
expect(server.stderr).not.toContain('AAAAAAAAAAAAAAAA');
|
||
});
|
||
|
||
test('requests on non-endpoint routes and methods are not authenticated or audited even with a valid token', async ({ cli, startServer, http }) => {
|
||
const { server, active } = await setup(cli, startServer);
|
||
expectErrorResponse(await http.get(server.url('/api/v1/containers/'), { token: active }), 404);
|
||
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token: active }), 405);
|
||
expectErrorResponse(await http.fetch(server.url(), { method: 'DELETE', token: 'garbage' }), 405);
|
||
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ method: 'HEAD', target: '/api/v1/containers', headers: [`Authorization: Bearer ${active}`] }) });
|
||
expectErrorResponse(r.response!, 405, { head: true });
|
||
expect((await server.stop()).exitCode).toBe(0);
|
||
expect(authClasses(server), 'authentication never ran').toEqual([]);
|
||
expect(readOperations(server.dbPath)).toEqual([]);
|
||
});
|