fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)

The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-08 16:30:37 +02:00
1 parent ea032c8857
commit 70353882eb
15 files changed
+229 -30

No files matched your search

+2 -2
View File
@@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
"avgBp": "Durchschnittlicher Blutdruck",
"classification": "Klassifikation",
"bmiTitle": "Body-Mass-Index (BMI)",
@@ -634,7 +634,7 @@
"inTargetRange": "im Zielbereich",
"bpTarget": "Ziel: {range}",
"noDobSet": "Geburtsdatum nicht hinterlegt.",
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
"whoClassification": "WHO-Klassifikation",
"correlations": "Korrelationen",
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
+2 -2
View File
@@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
"avgBp": "Average blood pressure",
"classification": "Classification",
"bmiTitle": "Body Mass Index (BMI)",
@@ -634,7 +634,7 @@
"inTargetRange": "in target range",
"bpTarget": "Target: {range}",
"noDobSet": "Date of birth not set.",
"avg30dEsc": "30d average · ESC/ESH 2018",
"avg30dEsc": "30d average · ESH 2023",
"whoClassification": "WHO Classification",
"correlations": "Correlations",
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
+7 -1
View File
@@ -372,7 +372,13 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});
// 8. Medication Compliance (average across active medications)
+1 -1
View File
@@ -130,7 +130,7 @@ ADVANCED METRICS:
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
+1 -1
View File
@@ -31,7 +31,7 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
+11 -8
View File
@@ -46,7 +46,7 @@ export function classifyBMI(bmi: number): BmiClassification {
};
}
// ── BP Classification (ESC/ESH 2018) ────────────────────
// ── BP Classification (ESH 2023) ────────────────────────
export interface BpClassification {
category: string;
@@ -244,9 +244,10 @@ export function classifyBodyFat(
// Cohort source: Saint-Maurice PF, et al. "Association of daily step
// count and step intensity with mortality among US adults." JAMA. 2020.
// https://jamanetwork.com/journals/jama/fullarticle/2763292
// Mortality benefit plateaus 8 000–12 000 steps/day. WHO publishes
// physical-activity TIME (150–300 min/wk moderate) — NOT a step
// quota; do not cite "WHO ≥ 8 000 steps".
// Mortality benefit accumulates strongly through ~12 000 steps/day
// (HR 0.49 at 8k vs 4k, HR 0.35 at 12k — continued dose-response,
// not a plateau). WHO publishes physical-activity TIME (150–300
// min/wk moderate) — NOT a step quota; do not cite "WHO ≥ 8 000 steps".
export interface StepsClassification {
category: string;
@@ -280,8 +281,8 @@ export function classifySteps(steps: number): StepsClassification {
/**
* Activity-steps target range. Single source of truth: aligned with
* `effective-range.ts` (Saint-Maurice JAMA 2020 — mortality plateau
* 8 000–12 000). The v1.3.3 audit flagged a drift where this helper
* `effective-range.ts` (Saint-Maurice JAMA 2020 — continued mortality
* dose-response through ~12 000 steps/day). The v1.3.3 audit flagged a drift where this helper
* returned {7 000, 10 000} while `effective-range.ts` returned
* {8 000, 15 000}; both surfaces showed different "green" bands to the
* same user. They now agree.
@@ -319,7 +320,9 @@ export function getSleepDurationRange(): { min: number; max: number } {
}
/**
* BP target ranges based on ESC/ESH 2018 guidelines.
* BP target ranges based on ESH 2023 guidelines (numerically
* unchanged from the joint ESC/ESH 2018 document; the 2023 update
* is ESH-only since ESC withdrew from joint authoring).
*/
export function getBpTargetsByAge(
age: number,
@@ -329,7 +332,7 @@ export function getBpTargetsByAge(
if (age < 65) {
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
}
// 65+ (both 65–79 and ≥80 have the same targets per ESC/ESH)
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
}
+3 -2
View File
@@ -207,8 +207,9 @@ function defaultRange(
// AASM: 7–9h for adults; warning yellow either side.
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
case "ACTIVITY_STEPS":
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (continued
// dose-response benefit through ~12000 steps/day, not a plateau —
// HR 0.49 at 8k vs 4k, HR 0.35 at 12k). WHO 2020 PA guidelines publish minutes
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
// step quota. No upper bound in reality, orange over 25k caps edge
// detection.
+12 -3
View File
@@ -4,6 +4,7 @@ import type { User } from "@/generated/prisma/client";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
@@ -92,7 +93,11 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try { emitIfSampled(evt.toJSON()); } catch { /* logging must never crash the handler */ }
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
@@ -345,10 +350,14 @@ async function reportToGlitchtip(
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
message: err.message,
// Defence in depth: even though the WideEventBuilder already
// redacts on `setError()`, the GlitchTip path imports `err`
// directly. Apply the same redaction here so a Telegram bot
// token or external Bearer cannot leak via the incident UI.
message: redactSecrets(err.message),
level: "error",
type: err.name || "Error",
stack: err.stack,
stack: redactOptional(err.stack),
url: scrubbedUrl,
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional } from "../redact";
describe("redactSecrets", () => {
it("redacts Bearer tokens", () => {
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
"Authorization: Bearer [REDACTED]",
);
expect(redactSecrets("auth=Bearer whitespace_token")).toBe(
"auth=Bearer [REDACTED]",
);
});
it("redacts Telegram bot tokens in path-style URLs", () => {
expect(
redactSecrets(
"fetch failed: https://api.telegram.org/bot1234567890:AAEhBP-w-secret/sendMessage",
),
).toBe("fetch failed: https://api.telegram.org/bot[REDACTED]/sendMessage");
});
it("redacts query-string secrets", () => {
expect(redactSecrets("Withings webhook ?secret=abc123 failed")).toBe(
"Withings webhook ?secret=[REDACTED] failed",
);
expect(redactSecrets("OAuth ?code=xyz&state=abc")).toBe(
"OAuth ?code=[REDACTED]&state=abc",
);
expect(redactSecrets("api?api_key=secret")).toBe("api?api_key=[REDACTED]");
});
it("leaves non-secret-shaped strings alone", () => {
expect(redactSecrets("HTTP 503 from upstream")).toBe(
"HTTP 503 from upstream",
);
expect(redactSecrets("user-7 measurement saved")).toBe(
"user-7 measurement saved",
);
});
it("handles multiple secrets in one string", () => {
// Bearer matches `\S+` so it greedily consumes `hlk_x;` — that's
// safe (over-redaction is fine when the alternative is leaking).
expect(redactSecrets("Bearer hlk_x bot999:tok ?code=abc123")).toBe(
"Bearer [REDACTED] bot[REDACTED] ?code=[REDACTED]",
);
});
});
describe("redactOptional", () => {
it("returns undefined for undefined input", () => {
expect(redactOptional(undefined)).toBeUndefined();
});
it("redacts when present", () => {
expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]");
});
});
+6 -4
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { WideEvent, LogLevel, EventKind } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
@@ -61,14 +62,14 @@ export class WideEventBuilder {
if (err instanceof Error) {
this.event.error = {
type: err.constructor.name,
message: err.message,
stack: err.stack,
message: redactSecrets(err.message),
stack: redactOptional(err.stack),
code: (err as { statusCode?: number }).statusCode,
};
} else {
this.event.error = {
type: "Unknown",
message: String(err),
message: redactSecrets(String(err)),
};
}
return this;
@@ -88,7 +89,8 @@ export class WideEventBuilder {
}
addDbQuery(durationMs: number): this {
if (!this.event.db) this.event.db = { query_count: 0, query_duration_ms: 0 };
if (!this.event.db)
this.event.db = { query_count: 0, query_duration_ms: 0 };
this.event.db.query_count++;
this.event.db.query_duration_ms += durationMs;
return this;
+35
View File
@@ -0,0 +1,35 @@
/**
* Redact secrets from strings before they leave the process boundary
* (Loki, Glitchtip, console). Applied in `WideEventBuilder.setError()`
* and `reportToGlitchtip()` so every error reaching observability is
* scrubbed once, centrally.
*
* Patterns:
* - `Bearer <token>` — generic API tokens incl. our `hlk_` /
* `hlr_` formats and external OAuth bearers (Withings, AI providers).
* - `bot<digits>:<token>` — Telegram bot URLs embed the token in the
* path: `https://api.telegram.org/bot1234567:ABC-…/sendMessage`.
* If `fetch()` rejects with a `cause` that surfaces the URL (some
* Node runtimes), the token would land in error reports.
* - `?secret=…` and `?code=…` — query-string leaks for legacy
* Withings webhooks and OAuth callbacks (already scrubbed at the
* URL level in `reportToGlitchtip`, but error.message can carry
* the URL too).
*
* The substitution is intentionally generic ([REDACTED]) — we don't
* want partial revelation of token entropy.
*/
export function redactSecrets(input: string): string {
return input
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
.replace(
/([?&])(secret|code|token|api[_-]?key)=[^&\s]+/gi,
"$1$2=[REDACTED]",
);
}
/** Apply `redactSecrets` to a possibly-undefined string. */
export function redactOptional(s: string | undefined): string | undefined {
return s === undefined ? undefined : redactSecrets(s);
}
+7 -4
View File
@@ -52,9 +52,12 @@ export const CITATIONS = {
/**
* Saint-Maurice PF, et al. "Association of daily step count and step
* intensity with mortality among US adults." JAMA. 2020.
* Mortality benefit plateaus 8 000–12 000 steps/day. WHO 2020 PA
* guidelines publish minutes per week, NOT a step quota — DO NOT
* cite WHO for a step number.
* The paper reports continued dose-response benefit (HR 0.49 at 8k
* vs 4k, HR 0.35 at 12k vs 4k) — NOT a plateau. The plateau-shaped
* finding belongs to Paluch 2022 Lancet Public Health (PMID
* 35247352, age-stratified meta-analysis). WHO 2020 PA guidelines
* publish minutes per week, NOT a step quota — DO NOT cite WHO
* for a step number.
*/
STEPS_SAINT_MAURICE_2020: {
id: "steps-saint-maurice-2020",
@@ -62,7 +65,7 @@ export const CITATIONS = {
year: 2020,
url: "https://jamanetwork.com/journals/jama/fullarticle/2763292",
caveat:
"U.S. adult cohort, observational; benefit plateaus 8–12k steps/day.",
"U.S. adult cohort, observational; benefit accumulates strongly through ~12k steps/day (continued dose-response, not a plateau).",
},
/**
+28 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Sync mood entries from a user's moodLog instance.
@@ -32,6 +33,18 @@ export async function syncMoodLogEntries(
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
// SSRF guard at the actual fetch site. The credential write path
// is also guarded (moodLogCredentialsSchema), but a row stored
// before that guard landed could still point at an internal IP.
// Re-checking here means the sync worker refuses internal targets
// even on legacy data, and the user's apiKey is never sent there.
if (!isPublicUrl(baseUrl)) {
getEvent()?.addWarning(
`moodLog sync refused for user ${userId}: stored URL points at non-public host`,
);
return 0;
}
// 2. Determine date range
const now = new Date();
const to = now.toISOString().slice(0, 10); // YYYY-MM-DD
@@ -61,6 +74,10 @@ export async function syncMoodLogEntries(
response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
// SSRF defence-in-depth: do NOT follow redirects. A public
// host that 302s to an RFC1918 target would otherwise leak
// the apiKey to the internal hop.
redirect: "manual",
});
} finally {
clearTimeout(timeout);
@@ -71,8 +88,18 @@ export async function syncMoodLogEntries(
});
}
// Treat any 3xx as failure (manual redirect mode surfaces them).
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`,
);
return 0;
}
if (!response.ok) {
getEvent()?.addWarning(`Sync failed for user ${userId}: HTTP ${response.status}`);
getEvent()?.addWarning(
`Sync failed for user ${userId}: HTTP ${response.status}`,
);
return 0;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { moodLogCredentialsSchema } from "../moodlog";
describe("moodLogCredentialsSchema SSRF guard", () => {
it("accepts a public moodLog URL", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "https://moodlog.app",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(true);
});
it("rejects RFC1918 URLs", () => {
for (const url of [
"http://10.0.0.1",
"http://192.168.1.1",
"http://172.16.0.1",
"http://127.0.0.1",
]) {
const r = moodLogCredentialsSchema.safeParse({
url,
apiKey: "k".repeat(40),
});
expect(r.success, `expected reject for ${url}`).toBe(false);
}
});
it("rejects link-local (cloud metadata) URLs", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
it("rejects localhost", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://localhost:8080",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
});
+13 -1
View File
@@ -1,7 +1,19 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
export const moodLogCredentialsSchema = z.object({
url: z.string().url().max(500),
url: z
.string()
.url()
.max(500)
// SSRF guard: stored URL must point at a public host. The sync
// worker fetches from this URL with the user's apiKey in the
// Authorization header, so a stored RFC1918 / link-local target
// would let any user pull cloud-metadata or local-network data
// back through their account.
.refine((u) => isPublicUrl(u), {
message: "URL must point at a public host (no RFC1918 / link-local)",
}),
apiKey: z.string().min(1).max(200),
});