mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
reconcile: fold in the outstanding hardening and data-integrity fixes
Merge the eight reviewed audit fixes that were cut a week ago and never landed. Main has moved on since, so each fix was re-checked against the current tree and each of the five conflicts resolved by understanding both sides. Kept, still open on main: - Proxy early exits now carry the transport security header subset via a shared helper. Reconciled with main's /api/health worker-only carve-out. - Batch measurement ingest bounds the entry instant so a future-dated row can no longer win "latest reading" or poison the rollup buckets. - Web Push dials through the pinned safeFetch dispatcher with requirePublicHost, closing the last user-controlled-host egress. - Mood bulk upsert and its tag/factor links run in one transaction, so a rejected rating rolls the mood row back instead of leaving a tagless row. - Full backup carries NutrientIntakeDay with its unit and source. Folded into main's portable-export / disaster-recovery split. - Live daily-series fallback restores the min/max spread band the tiered read already emitted. - Coach stream aborts its in-flight reader on unmount and resets send state on a conversation switch. - Manual water writes share the day-bound helper, nutrient add clamps after the increment, and the thiamin cap drops to a real supplement ceiling. - Compliance point window widens symmetrically instead of wrapping overnight. - Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx body as a handled transient error. - WebAuthn relying-party fallback to localhost is gated to development. - Chart-token strip list is derived from the allowlist and pinned by a test. - Regression tests pin the snapshot-cache tenant key, the GlitchTip path-secret redaction and the Withings credentials delete. Superseded by main, tests re-pointed at the current shape: - The intake CSV import was rewritten on main to run through a background job with a single-active-job admission gate. The future-date bound is re-applied at request validation and the per-user limiter is kept for parity with the bulk twin; the old synchronous-write test is replaced.
This commit is contained in:
commit
b1e36d9fb0
51 files changed
+2066
-228
No files matched your search
+67
-19
@@ -188,6 +188,40 @@ function generateNonce(): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-level security headers every response carries, including the early
|
||||
* exits (worker-only 503, legacy 301s, demo-mode 403, and the login /
|
||||
* onboarding / MFA redirects) that return before the full header block below.
|
||||
*
|
||||
* Those exits used to answer with no HSTS, no framing refusal and no nosniff at
|
||||
* all. The gap was one hop — a redirect has no rendered body and the follow-up
|
||||
* navigation re-enters this function and does get the full set — but "the next
|
||||
* request will be protected" is not a reason for this one not to be, and HSTS
|
||||
* in particular is worth the most on the first hop over a hostile network.
|
||||
*
|
||||
* Deliberately the transport subset, not the whole block: the per-route CSP
|
||||
* below is nonce-bound and carries carve-outs for the document-serve and share
|
||||
* routes that only make sense for a response with a body to protect.
|
||||
*/
|
||||
function applyBaselineSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.headers.set("X-Permitted-Cross-Domain-Policies", "none");
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
@@ -195,26 +229,32 @@ export function proxy(request: NextRequest) {
|
||||
// serving requests that would either crash (no DB pool ready for writes from
|
||||
// the worker) or duplicate work that the dedicated web container is doing.
|
||||
if (!shouldRunWeb() && pathname !== "/api/health") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"This container runs the worker only — point HTTP at the web service.",
|
||||
},
|
||||
{ status: 503, headers: { "X-HealthLog-Process-Type": "worker" } },
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"This container runs the worker only — point HTTP at the web service.",
|
||||
},
|
||||
{ status: 503, headers: { "X-HealthLog-Process-Type": "worker" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 301 redirects for renamed routes
|
||||
const redirect = LEGACY_REDIRECTS[pathname];
|
||||
if (redirect) {
|
||||
return NextResponse.redirect(new URL(redirect, request.url), 301);
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL(redirect, request.url), 301),
|
||||
);
|
||||
}
|
||||
|
||||
// 301 for the v1.5 admin section-anchor → dynamic-route migration.
|
||||
const legacyAdmin = LEGACY_ADMIN_ANCHORS[pathname];
|
||||
if (legacyAdmin) {
|
||||
return NextResponse.redirect(new URL(legacyAdmin, request.url), 301);
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL(legacyAdmin, request.url), 301),
|
||||
);
|
||||
}
|
||||
|
||||
// Demo mode: block all mutations except login
|
||||
@@ -230,13 +270,15 @@ export function proxy(request: NextRequest) {
|
||||
(entry) => pathname === entry.path && method === entry.method,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true },
|
||||
},
|
||||
{ status: 403 },
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true },
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +294,9 @@ export function proxy(request: NextRequest) {
|
||||
if (isSessionGatedPage) {
|
||||
const hasSession = request.cookies.has("healthlog_session");
|
||||
if (!hasSession) {
|
||||
return NextResponse.redirect(new URL("/auth/login", request.url));
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL("/auth/login", request.url)),
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.22 C4 — server-side onboarding redirect. Previously the
|
||||
@@ -272,7 +316,9 @@ export function proxy(request: NextRequest) {
|
||||
const isOnboardingSurface =
|
||||
pathname === "/onboarding" || pathname.startsWith("/onboarding/");
|
||||
if (onboardingPending && !isOnboardingSurface) {
|
||||
return NextResponse.redirect(new URL("/onboarding", request.url));
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL("/onboarding", request.url)),
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — admin-enforced MFA forced-enrollment gate. When the operator
|
||||
@@ -289,7 +335,9 @@ export function proxy(request: NextRequest) {
|
||||
const isMfaEnrollSurface =
|
||||
pathname === "/enroll-mfa" || pathname.startsWith("/settings/security");
|
||||
if (mfaEnrollRequired && !isMfaEnrollSurface && !isOnboardingSurface) {
|
||||
return NextResponse.redirect(new URL("/enroll-mfa", request.url));
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL("/enroll-mfa", request.url)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user