mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Closes issue 217. The isPublicUrl input-time guard catches every RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL DNS record can flip between accept and dispatch. The new pinnedPublicDispatcher resolves the hostname literally inside undici.Agent's connect hook, refuses any address isPublicIp would reject, and pins the connection to the first valid public address. The five outbound paths that accept a user-supplied host route through the dispatcher via safeFetch's requirePublicHost flag — moodlog sync, moodlog reverse-push, the moodlog credentials probe, the local AI client (already gated in commit 1), and the ntfy sender. The flag now buys both the input-time check and the connect-time pin in one call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
import { isPublicUrl } from "@/lib/validations/notifications";
|
|
import { getPinnedPublicDispatcher } from "@/lib/safe-fetch-dispatcher";
|
|
|
|
/**
|
|
* Defaults applied to every `safeFetch` call. Centralised so the values
|
|
* stay greppable and a future audit can verify the convention in one
|
|
* place rather than spread across ~20 outbound call sites.
|
|
*/
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
|
|
export type SafeFetchErrorKind = "private_host" | "timeout" | "network";
|
|
|
|
export interface SafeFetchOptions {
|
|
/**
|
|
* Follow 3xx responses. Defaults to `false`, i.e. `init.redirect` is
|
|
* pinned to `"manual"`. The opt-in exists so a caller with a legitimate
|
|
* redirect-following need (e.g. resolving a CDN's URL chain) can ask for
|
|
* the WHATWG default; flipping the convention from "remembered to opt
|
|
* in to manual" to "remembered to opt out" was the whole point of
|
|
* issue #218.
|
|
*/
|
|
followRedirects?: boolean;
|
|
/**
|
|
* Override the default 15 000 ms timeout. The timeout is composed with
|
|
* any caller-supplied `init.signal` via `AbortSignal.any`, so whichever
|
|
* fires first wins.
|
|
*/
|
|
timeoutMs?: number;
|
|
/**
|
|
* When true, run `isPublicUrl(url)` before dispatching. Throws
|
|
* `SafeFetchError({ kind: "private_host" })` if the URL points at an
|
|
* internal range, the cloud metadata endpoint, or any of the IPv4 /
|
|
* IPv6 alt-notation classes `isPublicUrl` catches. The DNS-rebinding
|
|
* follow-up (issue #217) extends this same flag with a connect-time
|
|
* IP pin via a dedicated undici dispatcher.
|
|
*/
|
|
requirePublicHost?: boolean;
|
|
/**
|
|
* Optional caller-owned signal. Composed with the timeout signal so a
|
|
* caller-side abort still wins even when the timeout has not yet
|
|
* elapsed.
|
|
*/
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* Typed wrapper around outbound fetch failures so callers can switch
|
|
* on `err.kind` instead of doing brittle string matching against
|
|
* `AbortError` / `TypeError`. Native fetch errors surface as
|
|
* `kind: "network"`; timeouts become `kind: "timeout"`; input-time
|
|
* private-host rejections become `kind: "private_host"`.
|
|
*/
|
|
export class SafeFetchError extends Error {
|
|
readonly kind: SafeFetchErrorKind;
|
|
readonly cause?: unknown;
|
|
|
|
constructor(message: string, kind: SafeFetchErrorKind, cause?: unknown) {
|
|
super(message);
|
|
this.name = "SafeFetchError";
|
|
this.kind = kind;
|
|
if (cause !== undefined) this.cause = cause;
|
|
}
|
|
}
|
|
|
|
function composeSignals(
|
|
caller: AbortSignal | undefined,
|
|
timeoutMs: number,
|
|
): AbortSignal {
|
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
if (!caller) return timeout;
|
|
return AbortSignal.any([caller, timeout]);
|
|
}
|
|
|
|
/**
|
|
* Default-safe outbound fetch wrapper.
|
|
*
|
|
* - `init.redirect` defaults to `"manual"` so a public host that 302s to
|
|
* `169.254.169.254` (cloud metadata) or an RFC1918 admin panel cannot
|
|
* coerce the client into leaking the request's `Authorization` header
|
|
* on the redirected hop. Callers that explicitly set `init.redirect`
|
|
* keep their choice; callers that pass `opts.followRedirects: true`
|
|
* get the WHATWG default of `"follow"`.
|
|
* - `init.signal` defaults to `AbortSignal.timeout(15_000)` so a tar-pit
|
|
* upstream cannot pin a worker indefinitely. Composes with any
|
|
* caller-owned signal via `AbortSignal.any`.
|
|
* - When `opts.requirePublicHost` is true, runs `isPublicUrl` first and
|
|
* throws `SafeFetchError({ kind: "private_host" })` on rejection. The
|
|
* DNS-rebinding hardening (issue #217) extends this flag with a
|
|
* connect-time IP pin via the pinned dispatcher.
|
|
*
|
|
* This is the documented egress entry point for every outbound call that
|
|
* does NOT target a hard-coded host (Anthropic / OpenAI / Apple /
|
|
* Telegram / Withings / GitHub — constant strings, no rebinding surface
|
|
* by construction). The four hard-coded hosts also route through it for
|
|
* convention consistency.
|
|
*/
|
|
export async function safeFetch(
|
|
url: string | URL,
|
|
init: RequestInit = {},
|
|
opts: SafeFetchOptions = {},
|
|
): Promise<Response> {
|
|
const target = url.toString();
|
|
|
|
if (opts.requirePublicHost && !isPublicUrl(target)) {
|
|
throw new SafeFetchError(
|
|
`safeFetch refused private or non-public host: ${target}`,
|
|
"private_host",
|
|
);
|
|
}
|
|
|
|
const redirect: RequestRedirect =
|
|
init.redirect ?? (opts.followRedirects ? "follow" : "manual");
|
|
|
|
const signal = composeSignals(
|
|
init.signal ?? opts.signal ?? undefined,
|
|
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
);
|
|
|
|
// When the caller asks for the public-host guard, also route through
|
|
// the pinned-IP dispatcher so a DNS rebinding cannot flip the host
|
|
// between the input-time `isPublicUrl` accept and the connect call.
|
|
// Node's `fetch` accepts an undici `dispatcher` on RequestInit; the
|
|
// DOM types don't reflect that, so we cast at the boundary.
|
|
const dispatcher = opts.requirePublicHost
|
|
? getPinnedPublicDispatcher()
|
|
: undefined;
|
|
const finalInit = {
|
|
...init,
|
|
redirect,
|
|
signal,
|
|
...(dispatcher ? { dispatcher } : {}),
|
|
} as RequestInit;
|
|
|
|
try {
|
|
return await fetch(target, finalInit);
|
|
} catch (err) {
|
|
// `AbortSignal.timeout` aborts with `TimeoutError` (`DOMException`
|
|
// named "TimeoutError"); a caller-side abort surfaces as
|
|
// `AbortError`. Both manifest as the same `err.name` check.
|
|
if (err instanceof DOMException) {
|
|
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
throw new SafeFetchError(
|
|
`safeFetch aborted (${err.name}): ${target}`,
|
|
"timeout",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
throw new SafeFetchError(
|
|
`safeFetch network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
"network",
|
|
err,
|
|
);
|
|
}
|
|
}
|