feat(security): safeFetch wrapper with manual redirect and timeout defaults

Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 22:32:11 +02:00
1 parent bf6dfb8f6d
commit 425503e095
24 files changed
+480 -148

No files matched your search

@@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -88,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let lastDetails = "";
for (const targetUrl of targetUrls) {
const upstream = await fetch(targetUrl, {
const upstream = await safeFetch(targetUrl, {
method: "POST",
headers: {
"content-type": "application/json",