diff --git a/src/lib/server/logging/redaction.test.ts b/src/lib/server/logging/redaction.test.ts index 2857565c..fe9292cb 100644 --- a/src/lib/server/logging/redaction.test.ts +++ b/src/lib/server/logging/redaction.test.ts @@ -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'; @@ -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( + `` + + `SignatureDoesNotMatchmismatch` + + `AKIA-CANARY` + + `STS-CANARY` + + `SIG-CANARY` + + `CR-CANARY` + + `` + ); + }); + await new Promise((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((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'); diff --git a/src/lib/server/logging/redaction.ts b/src/lib/server/logging/redaction.ts index 1136d666..1b5ed98c 100644 --- a/src/lib/server/logging/redaction.ts +++ b/src/lib/server/logging/redaction.ts @@ -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', @@ -22,6 +41,7 @@ export const redactionPaths: string[] = [ '*.authorization', '*.cookie', '*.set-cookie', + '*["x-storage-connection"]', '*.accessToken', '*.refreshToken', '*.idToken',