mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami
Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):
- /api/integrations/withings/test — pings wbsapi.withings.net /measure
with the decrypted user access token
- /api/integrations/moodlog/test — HEAD against the user's decrypted
moodLog URL (5 s timeout, isPublicUrl
SSRF guard)
- /api/notifications/web-push/test — sends "Push test" via web-push to
every PushSubscription, returns only
endpoint host (never full URL)
- /api/monitoring/glitchtip/test — admin-only; sends a stub event via
sendGlitchtipEvent() using the saved
AppSettings.glitchtipDsn
- /api/monitoring/umami/test — admin-only; HTTPS-only GET against
AppSettings.umamiScriptUrl, checks
body for "umami" marker
Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push
Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:
- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
and Umami test routes were querying `id: "default"` and would silently
return 422 in production. Tests now assert the where argument so the
typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
expired access token refreshes the same way the real sync does instead
of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
categorised separately from the HTTP status (e.g. `status: 401` →
credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
endpoint instead of a bare HEAD on the configured URL — a HEAD on the
bare URL would return 200 from a generic landing page even when the
API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
registered subscription, so a user with three devices got three buzzes
per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
failure (errors land in the meta envelope, not in the visible
response body) — the prior dead-code in the 401 sanitisation test is
gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
`credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
upcoming UI can localise via `t("settings.testConnection.errors." + code)`
instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
before they reach Wide Events / Loki — push providers in particular
embed the subscription endpoint (which is the routing secret) in
410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
cannot stream gigabytes through us; the marker check is tightened to
tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
can include the `errorCode` without breaking the existing
`{ data: null, error: <string> }` envelope.
477 tests green.
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
103 lines
3.7 KiB
TypeScript
103 lines
3.7 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.
|
|
*/
|
|
export function apiError(
|
|
message: string,
|
|
status = 400,
|
|
meta?: { errorCode?: string } & Record<string, unknown>,
|
|
) {
|
|
return NextResponse.json(
|
|
{ data: null, error: message, ...(meta ? { meta } : {}) },
|
|
{ status },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
const realIp = request.headers.get("x-real-ip");
|
|
return realIp && looksLikeIp(realIp) ? realIp : null;
|
|
}
|