Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/lib/server/logging/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import pino from 'pino';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3';
import { Writable } from 'node:stream';
import { describe, it, expect } from 'vitest';
import { redactionPaths, redactionCensor } from './redaction.js';
Expand Down Expand Up @@ -84,6 +87,85 @@ describe('redaction', () => {
expect(parsed.req.headers['set-cookie']).toBe('[Redacted]');
});

it('redacts the storage connection header, which carries the S3 secret key', () => {
const { log, lines } = createTestLogger();
const header = btoa(
JSON.stringify({ type: 's3', credentials: { accessKey: 'AK', secretKey: 'SK-CANARY' } })
);
log.info({ req: { headers: { 'x-storage-connection': header } } }, 'test');
log.flush();

expect(lines[0]).not.toContain(header);
expect(JSON.parse(lines[0]).req.headers['x-storage-connection']).toBe('[Redacted]');
});

it('redacts the storage connection header on a bare headers object', () => {
const { log, lines } = createTestLogger();
const header = btoa(JSON.stringify({ credentials: { secretKey: 'SK-CANARY' } }));
log.info({ headers: { 'x-storage-connection': header } }, 'test');
log.flush();

expect(lines[0]).not.toContain(header);
expect(JSON.parse(lines[0]).headers['x-storage-connection']).toBe('[Redacted]');
});

it('redacts the credential material an S3 endpoint echoes back', async () => {
const server = http.createServer((_req, res) => {
res.writeHead(403, { 'Content-Type': 'application/xml' });
res.end(
`<?xml version="1.0" encoding="UTF-8"?><Error>` +
`<Code>SignatureDoesNotMatch</Code><Message>mismatch</Message>` +
`<AWSAccessKeyId>AKIA-CANARY</AWSAccessKeyId>` +
`<StringToSign>STS-CANARY</StringToSign>` +
`<SignatureProvided>SIG-CANARY</SignatureProvided>` +
`<CanonicalRequest>CR-CANARY</CanonicalRequest>` +
`</Error>`
);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;

const client = new S3Client({
region: 'eu-central-1',
endpoint: `http://127.0.0.1:${port}`,
forcePathStyle: true,
credentials: { accessKeyId: 'AKIA-CANARY', secretAccessKey: 'x' },
maxAttempts: 1
});

const { log, lines } = createTestLogger();
try {
await client.send(new ListBucketsCommand({}));
throw new Error('expected the request to fail');
} catch (err) {
log.warn({ err }, 'storage connection test failed');
} finally {
client.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
log.flush();

const line = lines[0];
expect(line).not.toContain('AKIA-CANARY');
expect(line).not.toContain('STS-CANARY');
expect(line).not.toContain('SIG-CANARY');
expect(line).not.toContain('CR-CANARY');

// The diagnostics support actually needs must survive.
const parsed = JSON.parse(line);
expect(parsed.err.name).toBe('SignatureDoesNotMatch');
expect(parsed.err.$metadata.httpStatusCode).toBe(403);
}, 20000);

it('redacts echoed credentials nested in aggregated errors', () => {
const { log, lines } = createTestLogger();
const inner = Object.assign(new Error('inner'), { AWSAccessKeyId: 'AKIA-CANARY' });
log.warn({ err: new AggregateError([inner], 'all failed') }, 'test');
log.flush();

expect(lines[0]).not.toContain('AKIA-CANARY');
});

it('does not redact safe fields', () => {
const { log, lines } = createTestLogger();
log.info({ user_id: 'u123', path: '/api/test' }, 'test');
Expand Down
20 changes: 20 additions & 0 deletions src/lib/server/logging/redaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
const s3ErrorCredentialFields = [
'AWSAccessKeyId',
'StringToSign',
'StringToSignBytes',
'CanonicalRequest',
'CanonicalRequestBytes',
'SignatureProvided'
];

const s3ErrorPaths = s3ErrorCredentialFields.flatMap((field) => [
`*.${field}`,
`err.aggregateErrors[*].${field}`
]);

/** Pino redaction paths for sensitive fields. */
export const redactionPaths: string[] = [
// S3 error bodies echoed back by the remote endpoint
...s3ErrorPaths,

// Request headers
'req.headers.authorization',
'req.headers.cookie',
'req.headers["set-cookie"]',
// Carries the base64 JSON S3 connection config, including secretKey.
'req.headers["x-storage-connection"]',

// Token/credential fields (exact)
'accessToken',
Expand All @@ -22,6 +41,7 @@ export const redactionPaths: string[] = [
'*.authorization',
'*.cookie',
'*.set-cookie',
'*["x-storage-connection"]',
'*.accessToken',
'*.refreshToken',
'*.idToken',
Expand Down
Loading