mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
chore(observability): redact sensitive keys before wide-event excerpt + tighten Zod-issue audit
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.
This commit is contained in:
1 parent
dca9af9c71
commit
8eb74e6c76
21 files changed
+559
-29
No files matched your search
@@ -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<string, unknown> }).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<Record<string, unknown>>;
|
||||
};
|
||||
for (const issue of parsed.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string, unknown> }).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<Record<string, unknown>>;
|
||||
};
|
||||
for (const issue of parsed.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
-1
@@ -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<Omit<SanitisedZodIssue, "message">>;
|
||||
export function sanitiseZodIssues(
|
||||
issues: readonly ZodIssue[],
|
||||
options?: SanitiseZodIssueOptions,
|
||||
): SanitisedZodIssue[] | Array<Omit<SanitisedZodIssue, "message">> {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
||||
if (keyIsSensitive(key)) {
|
||||
out[key] = REDACTED;
|
||||
continue;
|
||||
}
|
||||
out[key] = redactSensitiveFields(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user