From d77c75aa7c61d9f29623611db925b9931daf68bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 16:30:00 +0200 Subject: [PATCH] fix(security): close moodLog SSRF + central error redaction; fix citation drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:), 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 Co-Authored-By: Marc-André Bombeck --- messages/de.json | 4 +- messages/en.json | 4 +- src/app/api/insights/targets/route.ts | 8 ++- src/lib/ai/prompts/base-system.ts | 2 +- src/lib/ai/prompts/general-status.ts | 2 +- src/lib/analytics/classifications.ts | 19 +++--- src/lib/analytics/effective-range.ts | 5 +- src/lib/api-handler.ts | 15 ++++- src/lib/logging/__tests__/redact.test.ts | 58 +++++++++++++++++++ src/lib/logging/event-builder.ts | 10 ++-- src/lib/logging/redact.ts | 35 +++++++++++ src/lib/medical-citations.ts | 11 ++-- src/lib/moodlog/sync.ts | 29 +++++++++- src/lib/validations/__tests__/moodlog.test.ts | 43 ++++++++++++++ src/lib/validations/moodlog.ts | 14 ++++- 15 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 src/lib/logging/__tests__/redact.test.ts create mode 100644 src/lib/logging/redact.ts create mode 100644 src/lib/validations/__tests__/moodlog.test.ts diff --git a/messages/de.json b/messages/de.json index 01f17e0..834c964 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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)", diff --git a/messages/en.json b/messages/en.json index 3dcea5e..0b2704c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index c92386a..83d6922 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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) diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 46a19d8..724235a 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -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. diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index 988b7b6..357be13 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -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. diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index de49e2c..ebdae36 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -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 }; } diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index b821eb7..9569ec1 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -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. diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 2ad4f66..b702701 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 Promise>( } 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(), diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts new file mode 100644 index 0000000..eeea3f5 --- /dev/null +++ b/src/lib/logging/__tests__/redact.test.ts @@ -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]"); + }); +}); diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 5effe95..bb08d82 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -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; diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts new file mode 100644 index 0000000..69ad0de --- /dev/null +++ b/src/lib/logging/redact.ts @@ -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 ` — generic API tokens incl. our `hlk_` / + * `hlr_` formats and external OAuth bearers (Withings, AI providers). + * - `bot:` — 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); +} diff --git a/src/lib/medical-citations.ts b/src/lib/medical-citations.ts index dca40ae..f780876 100644 --- a/src/lib/medical-citations.ts +++ b/src/lib/medical-citations.ts @@ -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).", }, /** diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts index b17d823..9d12223 100644 --- a/src/lib/moodlog/sync.ts +++ b/src/lib/moodlog/sync.ts @@ -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; } diff --git a/src/lib/validations/__tests__/moodlog.test.ts b/src/lib/validations/__tests__/moodlog.test.ts new file mode 100644 index 0000000..c7a812d --- /dev/null +++ b/src/lib/validations/__tests__/moodlog.test.ts @@ -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); + }); +}); diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index b0e263e..d05eac1 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -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), });