From 8eb74e6c76124ce01ce70e189ed49143cf9d3812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 11:05:56 +0200 Subject: [PATCH] chore(observability): redact sensitive keys before wide-event excerpt + tighten Zod-issue audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.4.48 received_shape_excerpt + zod_issues surface is safe for the current widget + series payloads (enum / boolean / int only) but the same pattern is heading for routes that accept free-text. Pre-empt the leak: introduce a shared redactSensitiveFields helper with a SENSITIVE_KEY_PATTERNS denylist (password / token / secret / apiKey / authorization / csrfState / nonce). The widget + series routes now route their body through it before the 256-char truncate. Audit-log sites that echoed Zod issues[].message into details now strip the message string to {path, code} pair — message could carry the offending user input verbatim for some Zod error codes. --- .../dashboard/widgets/__tests__/route.test.ts | 70 +++++++++- src/app/api/dashboard/widgets/route.ts | 20 ++- src/app/api/devices/route.ts | 10 +- src/app/api/ingest/medication/route.ts | 8 +- src/app/api/integrations/healthkit/route.ts | 8 +- src/app/api/measurements/[id]/route.ts | 10 +- src/app/api/measurements/route.ts | 22 ++- .../series/__tests__/route.test.ts | 43 ++++++ src/app/api/measurements/series/route.ts | 17 ++- .../medications/[id]/intake/import/route.ts | 10 +- src/app/api/medications/[id]/intake/route.ts | 19 ++- src/app/api/medications/intake/bulk/route.ts | 6 +- src/app/api/medications/intake/route.ts | 13 +- src/app/api/mood-entries/[id]/route.ts | 7 +- src/app/api/mood-entries/bulk/route.ts | 7 +- src/app/api/mood-entries/route.ts | 18 ++- src/app/api/tokens/route.ts | 7 +- src/lib/__tests__/api-response-zod.test.ts | 41 ++++++ src/lib/api-response.ts | 43 +++++- .../__tests__/redact-payload.test.ts | 130 ++++++++++++++++++ src/lib/observability/redact-payload.ts | 79 +++++++++++ 21 files changed, 559 insertions(+), 29 deletions(-) create mode 100644 src/lib/observability/__tests__/redact-payload.test.ts create mode 100644 src/lib/observability/redact-payload.ts diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 127f5d7e3..6f7f08801 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -145,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", expect(call.data.userId).toBe("user-1"); expect(call.data.action).toBe("dashboard.widgets.validation-failed"); const details = JSON.parse(call.data.details) as { - issues: Array<{ path: string; code: string; message: string }>; + issues: Array<{ path: string; code: string }>; }; expect(details.issues.length).toBe(2); for (const issue of details.issues) { - // Audit row carries the same sanitised shape — issue.params never - // hits the ledger either. - expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + // v1.4.49 — audit row is the persisted surface. `message` + // strips here so a future Zod code that embeds the offending + // value cannot leak into the ledger. + expect(Object.keys(issue).sort()).toEqual(["code", "path"]); } }); @@ -240,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", }; expect(body.details.issues.length).toBe(2); }); + + it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => { + // Caller adds a credential-shaped field — must not land in the + // wide-event excerpt verbatim. The denylist also covers nested + // members so `payload.apiKey` is redacted, while `version` / + // `widgets` stay readable for operator debug. + const payload = { + version: 2, + widgets: [], + apnsToken: "ff".repeat(32), + authorization: "Bearer leaked", + payload: { apiKey: "sk_live_xxx" }, + }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "dashboard.widgets.validation-failed", + ); + const meta = (annotated![0] as { meta?: Record }).meta!; + const excerpt = meta.received_shape_excerpt as string; + + // Sensitive values are never written into the excerpt. + expect(excerpt).not.toContain("ff".repeat(32)); + expect(excerpt).not.toContain("Bearer leaked"); + expect(excerpt).not.toContain("sk_live_xxx"); + // The redactor leaves the literal sentinel behind so an operator + // can still see the shape of the rejected payload. + expect(excerpt).toContain("[redacted]"); + // Non-sensitive keys still surface for debugging. + expect(meta.received_keys).toEqual( + expect.arrayContaining([ + "version", + "widgets", + "apnsToken", + "authorization", + "payload", + ]), + ); + }); + + it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => { + // The wide-event meta keeps full message for operator debugging, + // but the persisted auditLog row must only carry `path` + `code` + // so a future Zod code that embeds the offending value cannot + // leak into the ledger. + await callPut(makeReq({ version: 2, widgets: [] })); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { + data: { details: string }; + }; + const parsed = JSON.parse(call.data.details) as { + issues: Array>; + }; + for (const issue of parsed.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "path"]); + } + }); }); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index a541ec65a..e4704cdfc 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -29,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { redactSensitiveFields } from "@/lib/observability/redact-payload"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id rendered by the Settings → @@ -150,10 +151,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { // the Zod rejection so a single wide-event line carries enough // detail to diagnose serialiser drift in `HealthLog-iOS`. We log // ONLY the top-level keys plus a hard 256-char JSON excerpt — never +<<<<<<< HEAD // the full body — so PII / token-like fields cannot leak. The // diagnostic shape is built by the shared `buildPayloadDiagnostic` - // helper (v1.4.49) so the widget + series routes can't drift. - const payloadDiagnostic = buildPayloadDiagnostic(body); + // helper (v1.4.49) so the widget + series routes can't drift; the + // body is routed through `redactSensitiveFields` first so any + // future field matching the denylist (password / token / secret / + // apiKey / authorization / csrfState / nonce) lands as the literal + // `"[redacted]"` instead of its raw value. + const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body)); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { @@ -173,12 +179,20 @@ export const PUT = apiHandler(async (request: NextRequest) => { ) ) { // Best-effort breadcrumb — never block the 422 on a write miss. + // v1.4.49 — strip `message` from the audit-ledger row so Zod + // codes that embed the offending value in their default message + // (`invalid_enum_value` and similar) cannot leak user content + // through the audit surface. The wide-event excerpt above + // already carries the shape signal for operator debugging. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "dashboard.widgets.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 3311b5ba8..436df7e67 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "devices.register.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row. Device + // registration carries `token` + `apnsToken`; the default Zod + // message for `invalid_format` echoes the offending value, which + // would persist an APNs token (or a forged near-miss) into the + // audit ledger. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "devices.register.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 0b62f771d..d3a937178 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "ingest.medication.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; external + // ingest carries free-text `medicationName` + opaque + // `idempotencyKey`. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: apiToken.userId, action: "ingest.medication.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e9abb88d7..44951704f 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => { action: { name: "integrations.healthkit.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // HealthKit patch payload carries caller-provided `id` + `kind` + // strings that Zod can echo. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "integrations.healthkit.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 0b897d146..47f85b862 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -70,12 +70,20 @@ export const PUT = apiHandler( action: { name: "measurements.update.validation-failed" }, meta: { issue_count: issues.length, measurement_id: id }, }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // update schema carries free-text `notes`. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "measurements.update.validation-failed", - details: JSON.stringify({ issues, measurementId: id }), + details: JSON.stringify({ + issues: auditIssues, + measurementId: id, + }), }, }) .catch(() => { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ec45296f8..97e3dd57f 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "measurements.list.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row to keep + // any free-text query slice (custom `dayKey`, attacker-shaped + // `type=`) out of the persisted breadcrumb. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "measurements.list.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { @@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) { action: { name: "measurements.create.batch.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; batch + // rows carry free-text `notes` per measurement. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "measurements.create.batch.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { @@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) { action: { name: "measurements.create.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; single + // measurement carries free-text `notes`. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "measurements.create.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 695feece9..eb83aaf38 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -219,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => const res = await GET(req("kind=garbage&days=0")); expect(res.status).toBe(422); }); + + it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // An attacker-shaped query that mixes a valid `kind` with a + // credential-shaped `token` param. The excerpt must keep `kind` + // readable for operator debug but redact the token verbatim. + const res = await GET( + req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"), + ); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "measurements.series.validation-failed", + ); + const meta = (annotated![0] as { meta?: Record }).meta!; + const excerpt = meta.received_shape_excerpt as string; + + expect(excerpt).not.toContain("hlk_secret_value"); + expect(excerpt).not.toContain("sk_live_xxx"); + expect(excerpt).toContain("[redacted]"); + // The key inventory still surfaces so operators see the shape. + expect(meta.received_keys).toEqual( + expect.arrayContaining(["kind", "days", "token", "apiKey"]), + ); + }); + + it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await GET(req("kind=garbage&days=0")); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { + data: { details: string }; + }; + const parsed = JSON.parse(call.data.details) as { + issues: Array>; + }; + for (const issue of parsed.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "path"]); + } + }); }); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 05064a9b5..5eb633a5f 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -20,6 +20,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { redactSensitiveFields } from "@/lib/observability/redact-payload"; import type { MeasurementType } from "@/generated/prisma/client"; const kindEnum = z.enum([ @@ -81,9 +82,13 @@ export const GET = apiHandler(async (request: NextRequest) => { // the Zod rejection. Top-level keys + a hard 256-char JSON excerpt // only; never the full payload, so token-shaped query params (we // do not currently accept any but the truncation keeps that +<<<<<<< HEAD // invariant cheap) cannot leak. Shared helper via v1.4.49 so the - // widget + series routes can't drift on the diagnostic shape. - const payloadDiagnostic = buildPayloadDiagnostic(rawQuery); + // widget + series routes can't drift on the diagnostic shape; the + // query map is routed through `redactSensitiveFields` first so any + // future credential-shaped query string (`token`, `apiKey`, + // `csrfState`) redacts to `"[redacted]"` before the truncate. + const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery)); annotate({ action: { name: "measurements.series.validation-failed" }, meta: { @@ -92,12 +97,18 @@ export const GET = apiHandler(async (request: NextRequest) => { zod_issues: issues, }, }); + // v1.4.49 — strip `message` from the audit-ledger row so Zod + // codes that embed the offending value (`invalid_enum_value` etc.) + // cannot leak user content through the audit surface. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "measurements.series.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index ac66aa458..4c10957af 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -62,12 +62,20 @@ export const POST = apiHandler( action: { name: "medications.intake.import.validation-failed" }, meta: { issue_count: issues.length, medication_id: id }, }); + // v1.4.49 — strip `message` from the audit-ledger row; CSV + // imports carry caller-provided strings that Zod may echo. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.import.validation-failed", - details: JSON.stringify({ issues, medicationId: id }), + details: JSON.stringify({ + issues: auditIssues, + medicationId: id, + }), }, }) .catch(() => { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b32611a74..445e2aa6c 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { action: { name: "medications.intake.create.validation-failed" }, meta: { issue_count: issues.length, medication_id: id }, }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // intake payload carries `idempotencyKey` (opaque caller string). + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.create.validation-failed", - details: JSON.stringify({ issues, medicationId: id }), + details: JSON.stringify({ + issues: auditIssues, + medicationId: id, + }), }, }) .catch(() => { @@ -211,12 +219,19 @@ export const GET = apiHandler( action: { name: "medications.intake.list.validation-failed" }, meta: { issue_count: issues.length, medication_id: id }, }); + // v1.4.49 — strip `message` from the audit-ledger row. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.list.validation-failed", - details: JSON.stringify({ issues, medicationId: id }), + details: JSON.stringify({ + issues: auditIssues, + medicationId: id, + }), }, }) .catch(() => { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 59488331a..699101116 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise { action: { name: "medications.intake.bulk.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.bulk.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 3acff8668..57afe44b2 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "medications.intake.list.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; iOS-sent + // query strings can flow into Zod issue messages. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.list.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { @@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "medications.intake.update.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "medications.intake.update.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 7cec7f92e..35398919b 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -75,12 +75,17 @@ export const PUT = apiHandler( action: { name: "mood-entries.update.validation-failed" }, meta: { issue_count: issues.length, moodEntryId: id }, }); + // v1.4.49 — strip `message` from the audit-ledger row; mood + // update carries free-text `note` + `tags`. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "mood-entries.update.validation-failed", - details: JSON.stringify({ issues, moodEntryId: id }), + details: JSON.stringify({ issues: auditIssues, moodEntryId: id }), }, }) .catch(() => { diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index ab445e213..9af714e1f 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise { action: { name: "mood.bulk.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; bulk mood + // entries carry free-text `note` + `tags` per row. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "mood.bulk.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 3a3e1c5ca..22805b071 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "mood-entries.list.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row so Zod + // codes that embed the offending value (`invalid_enum_value` etc.) + // cannot leak user-typed content. Mood-entries carry free-text + // `note` + `tags`; defensive strip everywhere mood content can + // reach a Zod issue. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "mood-entries.list.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { @@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) { action: { name: "mood-entries.create.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // mood-create schema's free-text `note` + `tags` could land in a + // Zod issue message verbatim. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "mood-entries.create.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index f8766246b..c8a2a0f9b 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "tokens.create.validation-failed" }, meta: { issue_count: issues.length }, }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // mint payload carries a free-text `name`. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); prisma.auditLog .create({ data: { userId: user.id, action: "tokens.create.validation-failed", - details: JSON.stringify({ issues }), + details: JSON.stringify({ issues: auditIssues }), }, }) .catch(() => { diff --git a/src/lib/__tests__/api-response-zod.test.ts b/src/lib/__tests__/api-response-zod.test.ts index 2758da691..5f45b2b3d 100644 --- a/src/lib/__tests__/api-response-zod.test.ts +++ b/src/lib/__tests__/api-response-zod.test.ts @@ -123,4 +123,45 @@ describe("returnAllZodIssues", () => { // from the JSON envelope, matching apiError's contract. expect(body.meta?.headers).toBeUndefined(); }); + + // v1.4.49 — opt-in `stripValuesFromMessage` mode for audit-ledger + // writes. The default Zod message for `invalid_enum_value` and a + // handful of other codes embeds the offending value verbatim; an + // audit-row write on a free-text route would persist user content. + // The opt-in keeps the additive multi-issue envelope shape but + // drops `message` entirely so only `path` + `code` survive. + describe("sanitiseZodIssues with stripValuesFromMessage", () => { + it("returns { path, code } only when the option is set", () => { + const enumSchema = z.object({ + kind: z.enum(["weight", "pulse"]), + }); + const parsed = enumSchema.safeParse({ kind: "garbage-value-xxx" }); + if (parsed.success) throw new Error("expected failure"); + + const stripped = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + expect(stripped.length).toBe(1); + for (const issue of stripped) { + expect(Object.keys(issue).sort()).toEqual(["code", "path"]); + // The offending value must not survive on any returned key. + expect(JSON.stringify(issue)).not.toContain("garbage-value-xxx"); + } + }); + + it("default behaviour (no option) keeps message intact", () => { + const enumSchema = z.object({ + kind: z.enum(["weight", "pulse"]), + }); + const parsed = enumSchema.safeParse({ kind: "garbage" }); + if (parsed.success) throw new Error("expected failure"); + + const kept = sanitiseZodIssues(parsed.error.issues); + expect(kept[0]).toMatchObject({ + path: "kind", + code: expect.any(String), + message: expect.any(String), + }); + }); + }); }); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 874aa278e..0aeb74ad5 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -18,6 +18,25 @@ export interface SanitisedZodIssue { message: string; } +/** + * Variant used when the issues land in an audit-ledger row whose + * source field is free-text. `invalid_enum_value` and a handful of + * other Zod codes embed the offending value verbatim in + * `issue.message`, so a route that JSON-stringifies the sanitised + * array into `details` can leak user content. Callers opt in via + * `sanitiseZodIssues(error.issues, { stripValuesFromMessage: true })`. + */ +export interface SanitiseZodIssueOptions { + /** + * When true, drop `issue.message` from the returned shape entirely. + * Only `path` + `code` survive — enough for an operator to triage + * the rejection without persisting user-typed content. Default is + * `false` so the additive multi-issue envelope keeps its existing + * client contract. + */ + stripValuesFromMessage?: boolean; +} + /** * v1.4.42 W2 — multi-issue Zod error envelope. * @@ -33,10 +52,32 @@ export interface SanitisedZodIssue { * Privacy: only `path`, `code` and `message` are echoed. `issue.params` * (which can carry the raw rejected value for some Zod issue codes) * stays server-side. + * + * v1.4.49 — `stripValuesFromMessage` removes `message` for the + * auditLog-emission sites whose input fields are free-text. Some Zod + * codes (e.g. `invalid_enum_value`) embed the offending value in the + * default message string; a free-text route writing the message into + * the audit ledger would leak user content. The opt-in is additive — + * the default behaviour is unchanged so existing clients reading + * `details.issues[*].message` over the wire keep working. */ export function sanitiseZodIssues( issues: readonly ZodIssue[], -): SanitisedZodIssue[] { +): SanitisedZodIssue[]; +export function sanitiseZodIssues( + issues: readonly ZodIssue[], + options: { stripValuesFromMessage: true }, +): Array>; +export function sanitiseZodIssues( + issues: readonly ZodIssue[], + options?: SanitiseZodIssueOptions, +): SanitisedZodIssue[] | Array> { + if (options?.stripValuesFromMessage) { + return issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })); + } return issues.map((issue) => ({ path: issue.path.join("."), code: issue.code, diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts new file mode 100644 index 000000000..40db5cc1e --- /dev/null +++ b/src/lib/observability/__tests__/redact-payload.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + redactSensitiveFields, + SENSITIVE_KEY_PATTERNS, +} from "../redact-payload"; + +// v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt` +// surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must +// strip values for any key matching the SENSITIVE_KEY_PATTERNS denylist +// before the caller's `JSON.stringify` lands in the meta excerpt. + +describe("redactSensitiveFields", () => { + it("passes a body with no sensitive keys through unchanged", () => { + const body = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + comparisonBaseline: "lastMonth", + }; + const result = redactSensitiveFields(body); + expect(result).toEqual(body); + // Returns a clone, not the original reference. + expect(result).not.toBe(body); + }); + + it("redacts a top-level `password` key", () => { + const body = { username: "alice", password: "hunter2" }; + expect(redactSensitiveFields(body)).toEqual({ + username: "alice", + password: "[redacted]", + }); + }); + + it("redacts `Authorization` regardless of case (Bearer prefix preserved as redacted)", () => { + const body = { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.xxx" }; + expect(redactSensitiveFields(body)).toEqual({ + Authorization: "[redacted]", + }); + }); + + it("redacts a nested object's sensitive fields recursively", () => { + const body = { + kind: "device.register", + payload: { + deviceId: "abc-123", + apnsToken: "ff".repeat(32), + meta: { secret: "shh", model: "iPhone15" }, + }, + }; + expect(redactSensitiveFields(body)).toEqual({ + kind: "device.register", + payload: { + deviceId: "abc-123", + apnsToken: "[redacted]", + meta: { secret: "[redacted]", model: "iPhone15" }, + }, + }); + }); + + it("redacts inside array members", () => { + const body = { + entries: [ + { id: "1", apiKey: "sk_live_xxx" }, + { id: "2", token: "bearer-xyz" }, + { id: "3", note: "free text" }, + ], + }; + expect(redactSensitiveFields(body)).toEqual({ + entries: [ + { id: "1", apiKey: "[redacted]" }, + { id: "2", token: "[redacted]" }, + { id: "3", note: "free text" }, + ], + }); + }); + + it("matches the token denylist patterns case-insensitively (apnsToken, bearerToken, csrf_state)", () => { + const body = { + apnsToken: "ff".repeat(32), + bearerToken: "abc", + csrf_state: "xyz", + csrfState: "xyz2", + api_key: "sk_xxx", + Nonce: "01234", + randomField: "kept", + }; + expect(redactSensitiveFields(body)).toEqual({ + apnsToken: "[redacted]", + bearerToken: "[redacted]", + csrf_state: "[redacted]", + csrfState: "[redacted]", + api_key: "[redacted]", + Nonce: "[redacted]", + randomField: "kept", + }); + }); + + it("passes scalars + null + undefined through unchanged", () => { + expect(redactSensitiveFields(null)).toBeNull(); + expect(redactSensitiveFields(undefined)).toBeUndefined(); + expect(redactSensitiveFields("string")).toBe("string"); + expect(redactSensitiveFields(42)).toBe(42); + expect(redactSensitiveFields(true)).toBe(true); + }); + + it("does not recurse into non-plain objects (Date stays intact)", () => { + const date = new Date("2026-05-22T10:00:00Z"); + const body = { measuredAt: date, password: "x" }; + const result = redactSensitiveFields(body) as Record; + expect(result.measuredAt).toBe(date); + expect(result.password).toBe("[redacted]"); + }); + + it("exposes the SENSITIVE_KEY_PATTERNS denylist as a readonly tuple", () => { + // Pin the public surface so a future commit can't silently drop + // one of the documented patterns. + const required = [ + "password", + "token", + "secret", + "apikey", + "authorization", + "csrfstate", + "nonce", + ]; + for (const key of required) { + const matched = SENSITIVE_KEY_PATTERNS.some((re) => re.test(key)); + expect(matched, `pattern set should match ${key}`).toBe(true); + } + }); +}); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts new file mode 100644 index 000000000..4e8b4967b --- /dev/null +++ b/src/lib/observability/redact-payload.ts @@ -0,0 +1,79 @@ +/** + * v1.4.49 — sensitive-field redactor for observability payloads. + * + * The v1.4.48 H-iOS-1 / H-iOS-2 work surfaces a 256-char truncated + * `JSON.stringify` of the request body to the wide-event + * `meta.received_shape_excerpt`. Today's iOS surface (widget layout + + * series query) only carries enum / boolean / int fields so a verbatim + * excerpt is safe, but the pattern is heading for routes that accept + * tokens (APNs registration, Withings re-auth, API tokens) and bearer + * credentials. Pre-empt the leak: every body that flows into a + * wide-event excerpt is first walked through `redactSensitiveFields`, + * which replaces any key matching one of the `SENSITIVE_KEY_PATTERNS` + * regexes with the literal string `"[redacted]"`. + * + * Adding a new sensitive token is a one-line append to + * `SENSITIVE_KEY_PATTERNS`. The match is case-insensitive and runs + * against the literal key name, so `apnsToken`, `bearerToken`, + * `APIKey`, `Authorization` and `csrf_state` all redact without + * extra spellings. + * + * The redactor recurses into nested objects + arrays so deeply-nested + * credentials (e.g. `{ creds: { password: "…" } }`) are caught the + * same way. Non-object scalar inputs pass through unchanged — the + * caller's `JSON.stringify` then truncates as before. + */ + +export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ + /password/i, + /token/i, + /secret/i, + /apikey/i, + /api[_-]key/i, + /authorization/i, + /csrfstate/i, + /csrf[_-]state/i, + /nonce/i, +]; + +const REDACTED = "[redacted]"; + +function keyIsSensitive(key: string): boolean { + for (const re of SENSITIVE_KEY_PATTERNS) { + if (re.test(key)) return true; + } + return false; +} + +/** + * Returns a deep clone of `body` with any key matching the sensitive + * denylist replaced by the literal `"[redacted]"`. Arrays + plain + * objects are recursed into; scalars, `null`, `undefined`, `Date` and + * other non-plain objects pass through unchanged. + * + * The clone is intentionally shallow on non-plain-object boundaries so + * the helper stays cheap on the hot 422 path; the only goal is to keep + * the credentialed key's value out of the eventual JSON excerpt. + */ +export function redactSensitiveFields(body: unknown): unknown { + if (body === null || body === undefined) return body; + if (Array.isArray(body)) { + return body.map((entry) => redactSensitiveFields(entry)); + } + if (typeof body !== "object") return body; + // Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll + // serialise to a benign form via JSON.stringify and we don't want to + // walk into framework internals. + const proto = Object.getPrototypeOf(body); + if (proto !== Object.prototype && proto !== null) return body; + + const out: Record = {}; + for (const [key, value] of Object.entries(body as Record)) { + if (keyIsSensitive(key)) { + out[key] = REDACTED; + continue; + } + out[key] = redactSensitiveFields(value); + } + return out; +}