Files
HealthLog/src/lib/moodlog/sync.ts
T
Marc-André Bombeck 70353882eb 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
2026-05-08 16:30:37 +02:00

159 lines
4.5 KiB
TypeScript

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.
* Fetches from the health-log export endpoint and upserts into local DB.
*/
export async function syncMoodLogEntries(
userId: string,
opts?: { fullSync?: boolean },
): Promise<number> {
// 1. Read user credentials
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
});
if (
!user?.moodLogEnabled ||
!user.moodLogUrlEncrypted ||
!user.moodLogApiKeyEncrypted
) {
return 0;
}
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
let from: string;
if (opts?.fullSync || !user.moodLogLastSyncedAt) {
// Full sync: fetch all available data
from = "2000-01-01";
} else {
// Incremental: from lastSyncedAt - 1 hour overlap
const d = new Date(user.moodLogLastSyncedAt.getTime() - 3600 * 1000);
from = d.toISOString().slice(0, 10);
}
// 3. Fetch from moodLog
const url = new URL("/api/integrations/health-log/mood", baseUrl);
url.searchParams.set("from", from);
url.searchParams.set("to", to);
const controller = new AbortController();
const timeoutMs = opts?.fullSync ? 60000 : 15000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
const fetchStart = performance.now();
try {
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);
getEvent()?.addExternalCall({
service: "moodlog",
method: "syncMoodLogEntries",
duration_ms: Math.round(performance.now() - fetchStart),
});
}
// 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}`,
);
return 0;
}
const data = await response.json();
// Validate response has entries array
if (!data?.entries || !Array.isArray(data.entries)) {
getEvent()?.addWarning(`Invalid response format for user ${userId}`);
return 0;
}
// 4. Upsert entries (note field is intentionally NOT imported)
let imported = 0;
for (const entry of data.entries) {
try {
const moodLoggedAt = new Date(entry.time);
const date = entry.date as string;
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert entry: ${err}`);
}
}
// 5. Update lastSyncedAt
await prisma.user.update({
where: { id: userId },
data: { moodLogLastSyncedAt: now },
});
return imported;
}