diff --git a/server/src/__tests__/http-log-redaction.test.ts b/server/src/__tests__/http-log-redaction.test.ts new file mode 100644 index 0000000000..46531b935d --- /dev/null +++ b/server/src/__tests__/http-log-redaction.test.ts @@ -0,0 +1,86 @@ +import { createServer, request } from "node:http"; +import { Writable } from "node:stream"; +import pino from "pino"; +import { pinoHttp } from "pino-http"; +import { describe, expect, it } from "vitest"; +import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js"; + +describe("HTTP logger redaction", () => { + it("defines the HTTP auth and cookie header paths that must be redacted", () => { + expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.authorization"); + expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.cookie"); + expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["set-cookie"]'); + expect(HTTP_LOG_REDACT_PATHS).toContain('res.headers["set-cookie"]'); + expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["proxy-authorization"]'); + expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-csrf-token"]'); + expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-xsrf-token"]'); + expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-api-key"]'); + }); + + it("redacts request and response header secrets from pino-http output", async () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const logger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream); + const httpLogger = pinoHttp({ logger }); + const server = createServer((req, res) => { + httpLogger(req, res); + res.setHeader("set-cookie", "sid=response-secret"); + res.end("ok"); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected server to listen on an ephemeral TCP port"); + } + + await new Promise((resolve, reject) => { + const client = request( + { + hostname: "127.0.0.1", + port: address.port, + path: "/redaction-check", + headers: { + authorization: "Bearer auth-secret", + cookie: "sid=request-secret", + "set-cookie": "proxy-secret", + }, + }, + (res) => { + res.resume(); + res.on("end", resolve); + }, + ); + client.on("error", reject); + client.end(); + }); + + await new Promise((resolve) => setImmediate(resolve)); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + + const output = chunks.join(""); + expect(output).not.toMatch(/auth-secret|request-secret|proxy-secret|response-secret/); + + const log = JSON.parse(output.trim()) as { + req: { headers: Record }; + res: { headers: Record }; + }; + expect(log.req.headers.authorization).toBe("[Redacted]"); + expect(log.req.headers.cookie).toBe("[Redacted]"); + expect(log.req.headers["set-cookie"]).toBe("[Redacted]"); + expect(log.res.headers["set-cookie"]).toBe("[Redacted]"); + }); +}); diff --git a/server/src/middleware/http-log-redaction.ts b/server/src/middleware/http-log-redaction.ts new file mode 100644 index 0000000000..bc4ba586a2 --- /dev/null +++ b/server/src/middleware/http-log-redaction.ts @@ -0,0 +1,13 @@ +export const HTTP_LOG_REDACT_PATHS = [ + "req.headers.authorization", + 'req.headers["proxy-authorization"]', + "req.headers.cookie", + // "set-cookie" is normally a response header; keep the request-side + // path as defensive coverage in case a proxy forwards it inbound. + 'req.headers["set-cookie"]', + 'res.headers["set-cookie"]', + // Credential- and session-paired headers with no debugging value. + 'req.headers["x-csrf-token"]', + 'req.headers["x-xsrf-token"]', + 'req.headers["x-api-key"]', +] as const; diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index e014a34ca6..2ce76d7250 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -4,6 +4,7 @@ import pino from "pino"; import { pinoHttp } from "pino-http"; import { readConfigFile } from "../config-file.js"; import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js"; +import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js"; import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js"; import { redactSensitive } from "./redact-sensitive.js"; @@ -30,7 +31,7 @@ const sharedOpts = { export const logger = pino({ level: "debug", - redact: ["req.headers.authorization"], + redact: [...HTTP_LOG_REDACT_PATHS], }, pino.transport({ targets: [ {