mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
HSTS gains `; preload` so the domain becomes eligible for the Chromium preload list. Withings CSP host moves from global to a path-gated entry (mirrors the existing AI-host gating) — closes a DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns once per process when `TRUST_PROXY_HOPS` and the XFF chain length disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so future callers can route to a tighter universal rate-limit bucket when the chain looks tampered with. Service worker now resolves push-notification payload URLs against `self.location.origin` and rejects off-origin redirects (falls back to `/`). The German-only offline fallback HTML body was replaced with a language-neutral icon + 'Offline' wordmark so EN / FR / ES / IT / PL users don't see German on a cold offline boot. Manifest gained `"scope": "/"`, `"id": "/?source=pwa"`, and `"display_override": ["standalone"]` so future sub-path deploys don't accidentally inherit root scope and install-prompt heuristics anchor on a stable id. +16 new test cases across two new files covering HSTS preload, CSP gating on/off, the once-per-process warning, the tagged helper's 4 shape branches, SW same-origin guard, offline-locale-neutrality, and manifest hygiene. Existing proxy/SW suites stay green.
196 lines
7.2 KiB
TypeScript
196 lines
7.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
|
|
export function apiSuccess<T>(data: T, status = 200) {
|
|
return NextResponse.json({ data, error: null }, { status });
|
|
}
|
|
|
|
/**
|
|
* `meta` is additive — clients that ignore it see the unchanged
|
|
* `{ data: null, error: <string> }` envelope. New callers can pass
|
|
* `{ errorCode: "credentials_rejected" }` so the UI translates the message
|
|
* via `t("settings.testConnection.errors." + errorCode)` instead of
|
|
* displaying the server's English fallback.
|
|
*
|
|
* v1.4.25 W21 Fix-N — the third argument may also carry a `headers`
|
|
* record (e.g. `{ headers: rateLimitHeaders(rl) }`) so 429 responses
|
|
* can attach the `X-RateLimit-*` triple in the same call. The `headers`
|
|
* key is *not* echoed into the JSON body — it is consumed by the
|
|
* NextResponse constructor and stripped from the meta envelope.
|
|
*/
|
|
export function apiError(
|
|
message: string,
|
|
status = 400,
|
|
meta?: {
|
|
errorCode?: string;
|
|
headers?: Record<string, string>;
|
|
} & Record<string, unknown>,
|
|
) {
|
|
const { headers, ...rest } = meta ?? {};
|
|
const metaKeys = Object.keys(rest);
|
|
return NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: message,
|
|
...(metaKeys.length > 0 ? { meta: rest } : {}),
|
|
},
|
|
{
|
|
status,
|
|
...(headers ? { headers } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Safely parse JSON body from a request.
|
|
* Returns the parsed body or a 400 error response if parsing fails.
|
|
*/
|
|
export async function safeJson<T = unknown>(
|
|
request: Request,
|
|
): Promise<{ data: T; error?: never } | { data?: never; error: Response }> {
|
|
const ct = request.headers.get("content-type");
|
|
if (!ct || !ct.includes("application/json")) {
|
|
return { error: apiError("Content-Type must be application/json", 415) };
|
|
}
|
|
try {
|
|
const data = (await request.json()) as T;
|
|
return { data };
|
|
} catch {
|
|
return { error: apiError("Invalid JSON body", 400) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the real client IP from a request, respecting trusted-proxy
|
|
* configuration. V3 audit: previously took the leftmost XFF entry blindly,
|
|
* letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat
|
|
* IP-based rate-limits.
|
|
*
|
|
* Trust model (`TRUST_PROXY_HOPS` env):
|
|
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
|
|
* (use this if HealthLog is internet-facing without a
|
|
* reverse proxy you control)
|
|
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
|
|
* Cloudflare-Tunnel single-proxy deployment); read the
|
|
* rightmost XFF entry which is the IP your proxy
|
|
* observed when the request arrived.
|
|
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
|
|
*
|
|
* Returns the resolved IP or null when no trusted source is available.
|
|
*/
|
|
function looksLikeIp(s: string): boolean {
|
|
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
|
|
}
|
|
|
|
function parseTrustProxyHops(raw: string | undefined): number {
|
|
if (raw === undefined) return 1;
|
|
const trimmed = raw.trim();
|
|
if (!/^\d+$/.test(trimmed)) {
|
|
// Reject explicitly-invalid values so an operator typo doesn't silently
|
|
// switch a real-proxy deployment to "no XFF trust" mode (review
|
|
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
|
|
throw new Error(
|
|
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
|
|
);
|
|
}
|
|
return parseInt(trimmed, 10);
|
|
}
|
|
|
|
/**
|
|
* Module-scope flag so the trust-violation warning fires at most once per
|
|
* process. F-6 (mobile security audit, 2026-05-16): every IP-keyed
|
|
* rate-limit caller falls back to a literal `"unknown"` bucket when
|
|
* `getClientIp` returns null, which collapses anonymous traffic into a
|
|
* single shared bucket. A persistent stderr line tells the operator the
|
|
* proxy chain length and the configured `TRUST_PROXY_HOPS` value don't
|
|
* match and the deployment is silently degrading rate-limit precision.
|
|
*/
|
|
let trustViolationWarned = false;
|
|
|
|
/**
|
|
* Reset the once-per-process warning flag. Test-only; the production
|
|
* code never calls this.
|
|
*/
|
|
export function _resetTrustViolationWarningForTests(): void {
|
|
trustViolationWarned = false;
|
|
}
|
|
|
|
function warnTrustViolationOnce(hops: number, chainLength: number): void {
|
|
if (trustViolationWarned) return;
|
|
trustViolationWarned = true;
|
|
console.warn(
|
|
`[getClientIp] TRUST_PROXY_HOPS=${hops} but X-Forwarded-For carried ${chainLength} entr${chainLength === 1 ? "y" : "ies"}; ` +
|
|
`refusing to read XFF for this request. Every anonymous caller will now share the same "unknown" rate-limit bucket — fix TRUST_PROXY_HOPS or the proxy chain.`,
|
|
);
|
|
}
|
|
|
|
export function getClientIp(request: Request): string | null {
|
|
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
|
|
|
if (hops > 0) {
|
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
if (forwarded) {
|
|
const chain = forwarded
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(looksLikeIp);
|
|
// Review finding M-3: when the chain is shorter than the configured
|
|
// hops count, the operator misconfigured TRUST_PROXY_HOPS or a
|
|
// proxy was bypassed. Falling back to the leftmost (now
|
|
// attacker-controlled) entry would re-introduce the very rotation
|
|
// attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF.
|
|
if (chain.length >= hops) {
|
|
return chain[chain.length - hops];
|
|
}
|
|
// F-6 (mobile security audit, 2026-05-16): emit a one-shot
|
|
// operator signal when the chain shape doesn't match the
|
|
// configured trust. Without this warning the silent degrade was
|
|
// invisible until rate-limits visibly misfired in production.
|
|
warnTrustViolationOnce(hops, chain.length);
|
|
}
|
|
}
|
|
const realIp = request.headers.get("x-real-ip");
|
|
return realIp && looksLikeIp(realIp) ? realIp : null;
|
|
}
|
|
|
|
/**
|
|
* Tagged return shape so a caller can apply a tighter universal
|
|
* rate-limit when the trust chain is misconfigured. F-6 (mobile security
|
|
* audit, 2026-05-16): callers today fall back to a literal `"unknown"`
|
|
* string, collapsing every anonymous request into one bucket. New
|
|
* callers should branch on `trustViolation === true` and route the
|
|
* request to a tighter global rate-limit instead of the per-IP one.
|
|
*
|
|
* Existing callers using `getClientIp(request) ?? "unknown"` keep
|
|
* working unchanged; this helper is additive.
|
|
*/
|
|
export function getClientIpOrTrustWarning(request: Request): {
|
|
ip: string | null;
|
|
trustViolation: boolean;
|
|
} {
|
|
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
|
|
|
if (hops > 0) {
|
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
if (forwarded) {
|
|
const chain = forwarded
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(looksLikeIp);
|
|
if (chain.length >= hops) {
|
|
return { ip: chain[chain.length - hops], trustViolation: false };
|
|
}
|
|
warnTrustViolationOnce(hops, chain.length);
|
|
const realIp = request.headers.get("x-real-ip");
|
|
return {
|
|
ip: realIp && looksLikeIp(realIp) ? realIp : null,
|
|
trustViolation: true,
|
|
};
|
|
}
|
|
}
|
|
const realIp = request.headers.get("x-real-ip");
|
|
return {
|
|
ip: realIp && looksLikeIp(realIp) ? realIp : null,
|
|
trustViolation: false,
|
|
};
|
|
}
|