From fe1391903a9ec3480e02927dc6e08e17652be11e Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:45:43 +0200 Subject: [PATCH] Redact credentials from logged HTTP request headers (#1054) The controller HTTP access log and the express error log wrote the full request headers at the http level, which both default log transports keep. Clients send their access token in X-Access-Token or Authorization: Bearer, so user and host tokens ended up in plain text in controller and cluster logs, and the cluster log can be read back over the API with core.log.query. Mask the authorization, x-access-token, cookie and proxy-authorization headers before logging. Co-authored-by: Claude Opus 5.5 --- packages/controller/src/Controller.ts | 20 ++++++++++++++++++-- test/controller/Controller.js | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 75a0e084..4d94f3af 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -409,7 +409,7 @@ export default class Controller { method: req.method, url: req.originalUrl, httpVersion: req.httpVersion, - headers: req.headers, + headers: Controller.loggableHeaders(req.headers), query: req.query, }, }); @@ -897,7 +897,7 @@ export default class Controller { statusCode: res.statusCode, responseTime: durationMs, httpVersion: req.httpVersion, - headers: req.headers, + headers: Controller.loggableHeaders(req.headers), query: req.query, }, }); @@ -1226,6 +1226,22 @@ export default class Controller { return url; } + /** + * Copy of request headers with credentials masked for logging + * + * @param headers - headers of the incoming request. + * @returns headers safe to write to the log. + */ + static loggableHeaders(headers: http.IncomingHttpHeaders) { + const result = { ...headers }; + for (const name of ["authorization", "cookie", "proxy-authorization", "x-access-token"]) { + if (result[name] !== undefined) { + result[name] = "[redacted]"; + } + } + return result; + } + /** * Serves the web interface with the root path set appropriately * diff --git a/test/controller/Controller.js b/test/controller/Controller.js index efcc69ee..26b80ba1 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -86,6 +86,24 @@ describe("controller/src/Controller", function() { await assert.rejects(Controller.loadJsonObject(path.join(dir, "invalid.json")), SyntaxError); }); }); + describe(".loggableHeaders()", function() { + it("should redact credentials and keep other headers", function() { + const headers = { + "authorization": "Bearer secret", + "x-access-token": "secret", + "cookie": "a=secret", + "user-agent": "test", + }; + assert.deepEqual(Controller.loggableHeaders(headers), { + "authorization": "[redacted]", + "x-access-token": "[redacted]", + "cookie": "[redacted]", + "user-agent": "test", + }); + assert.equal(headers["x-access-token"], "secret"); + }); + }); + describe(".serveWeb()", function() { let server, port; before(async function() {