From 44e8229ff3de0c8f601422bb792135b18a0d6b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 22:24:42 +0200 Subject: [PATCH] fix(logging): redact http.path + http.route in WideEventBuilder.setHttp Closes the WITHINGS_WEBHOOK_SECRET leak (sec-C1): the api-handler seeds http.path and http.route from url.pathname, which carries the shared secret for /api/withings/webhook/ requests. Routing both fields through redactSecrets() at setHttp() time keeps the secret out of stdout, the in-memory ring buffer, and Loki egress. --- .../logging/__tests__/event-builder.test.ts | 112 ++++++++++++++++++ src/lib/logging/event-builder.ts | 15 ++- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/lib/logging/__tests__/event-builder.test.ts diff --git a/src/lib/logging/__tests__/event-builder.test.ts b/src/lib/logging/__tests__/event-builder.test.ts new file mode 100644 index 000000000..ae12c5227 --- /dev/null +++ b/src/lib/logging/__tests__/event-builder.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { WideEventBuilder } from "../event-builder"; + +describe("WideEventBuilder.setHttp", () => { + it("stores plain http metadata as-is", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "GET", + path: "/api/health", + route: "/api/health", + status: 200, + }); + const json = evt.toJSON(); + expect(json.http).toMatchObject({ + method: "GET", + path: "/api/health", + route: "/api/health", + status: 200, + }); + }); + + // Fix-J (v1.4.25 W21, sec-C1): WITHINGS_WEBHOOK_SECRET lands in + // http.path on every request without this scrub. Verify the redaction + // is applied at setHttp time so the secret never reaches stdout, the + // in-memory ring buffer, or Loki. + it("redacts Withings webhook secret from http.path and http.route", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "POST", + path: "/api/withings/webhook/test-secret", + route: "/api/withings/webhook/test-secret", + status: 200, + }); + const http = evt.toJSON().http; + expect(http?.path).toBe("/api/withings/webhook/[REDACTED]"); + expect(http?.route).toBe("/api/withings/webhook/[REDACTED]"); + }); + + it("preserves trailing path segments after the redacted secret", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "GET", + path: "/api/withings/webhook/sup3r-secr3t/verify", + route: "/api/withings/webhook/[token]/verify", + status: 200, + }); + const http = evt.toJSON().http; + expect(http?.path).toBe("/api/withings/webhook/[REDACTED]/verify"); + // The Next.js route template `[token]` does not match the + // path-segment rule's `` shape boundary (the brackets are + // treated as a regular segment), so it is still redacted; either + // shape is acceptable since the goal is to never leak the secret. + expect(http?.route).toBe("/api/withings/webhook/[REDACTED]/verify"); + }); + + it("does not over-redact unrelated paths", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "GET", + path: "/api/measurements/123", + route: "/api/measurements/[id]", + status: 200, + }); + const http = evt.toJSON().http; + expect(http?.path).toBe("/api/measurements/123"); + expect(http?.route).toBe("/api/measurements/[id]"); + }); + + it("still scrubs query-string secrets via the existing rule", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "POST", + path: "/api/withings/webhook?secret=abc123", + route: "/api/withings/webhook", + status: 200, + }); + const http = evt.toJSON().http; + expect(http?.path).toBe("/api/withings/webhook?secret=[REDACTED]"); + expect(http?.route).toBe("/api/withings/webhook"); + }); + + it("preserves other http fields verbatim", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp({ + method: "POST", + path: "/api/withings/webhook/leak", + route: "/api/withings/webhook/leak", + status: 200, + user_agent: "WithingsClient/1.0", + ip: "10.0.0.1", + content_length: 42, + response_size: 17, + }); + const http = evt.toJSON().http; + expect(http).toMatchObject({ + method: "POST", + path: "/api/withings/webhook/[REDACTED]", + route: "/api/withings/webhook/[REDACTED]", + status: 200, + user_agent: "WithingsClient/1.0", + ip: "10.0.0.1", + content_length: 42, + response_size: 17, + }); + }); + + it("handles an undefined http payload without throwing", () => { + const evt = new WideEventBuilder("http"); + evt.setHttp(undefined); + expect(evt.toJSON().http).toBeUndefined(); + }); +}); diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index bb08d822d..cde3abd27 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -38,7 +38,20 @@ export class WideEventBuilder { } setHttp(http: WideEvent["http"]): this { - this.event.http = http; + if (http) { + // Fix-J (v1.4.25 W21): the Withings webhook ships its shared + // secret as a path segment, so `path` and `route` carry the + // secret unless we scrub on the way in. `redactSecrets` is the + // single egress-redaction surface (see redact.ts for the rules + // and the PATH_SECRET_PATHS registry). + this.event.http = { + ...http, + path: redactSecrets(http.path), + route: redactSecrets(http.route), + }; + } else { + this.event.http = http; + } return this; }