mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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
159 lines
4.5 KiB
TypeScript
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;
|
|
}
|