mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
ea032c8857
commit
70353882eb
15 files changed
+229
-30
No files matched your search
+2
-2
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user