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",
+2 -1
View File
@@ -11,6 +11,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { safeFetch } from "@/lib/safe-fetch";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -120,7 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// Create the issue
const issueRes = await fetch(
const issueRes = await safeFetch(
`https://api.github.com/repos/${ghRepo}/issues`,
{
method: "POST",
+16 -14
View File
@@ -6,6 +6,7 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -110,20 +111,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
probeUrl.searchParams.set("from", yesterday);
probeUrl.searchParams.set("to", today);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = performance.now();
try {
const res = await fetch(probeUrl.toString(), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
cache: "no-store",
// `manual` neutralises the redirect-follow SSRF where a public host
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
redirect: "manual",
});
// safeFetch keeps the manual-redirect guard (the apiKey would
// otherwise leak on a 302 to 169.254.169.254 / RFC1918) and adds
// the AbortSignal.timeout convention.
const res = await safeFetch(
probeUrl.toString(),
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
},
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
// 3xx in `manual` redirect mode → we never followed; treat as success
@@ -155,7 +157,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
} catch (e) {
const err = e as Error;
const isAbort = err.name === "AbortError";
const isAbort =
(e instanceof SafeFetchError && e.kind === "timeout") ||
err.name === "AbortError";
const code = isAbort ? "timeout" : "connection_failed";
annotate({
meta: {
@@ -168,7 +172,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
502,
{ errorCode: code },
);
} finally {
clearTimeout(timer);
}
});
+18 -14
View File
@@ -5,6 +5,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/withings/sync";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -95,21 +96,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = performance.now();
try {
const res = await fetch(WITHINGS_MEASURE_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${tokenInfo.accessToken}`,
const res = await safeFetch(
WITHINGS_MEASURE_URL,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${tokenInfo.accessToken}`,
},
body: params.toString(),
},
body: params.toString(),
signal: controller.signal,
redirect: "manual",
});
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
if (!res.ok) {
@@ -156,7 +157,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
} catch (e) {
const err = e as Error;
const isAbort = err.name === "AbortError";
// safeFetch wraps timeouts into SafeFetchError{kind:"timeout"};
// legacy AbortError name is preserved for compatibility with any
// future direct-fetch path that still reaches this catch.
const isAbort =
(e instanceof SafeFetchError && e.kind === "timeout") ||
err.name === "AbortError";
const code = isAbort ? "timeout" : "connection_failed";
annotate({
meta: {
@@ -169,8 +175,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
502,
{ errorCode: code },
);
} finally {
clearTimeout(timer);
}
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -25,7 +26,7 @@ export const GET = apiHandler(async () => {
});
}
const response = await fetch(settings.umamiScriptUrl, {
const response = await safeFetch(settings.umamiScriptUrl, {
next: { revalidate: 3600 },
});
if (!response.ok) {
+2 -1
View File
@@ -5,6 +5,7 @@ import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -73,7 +74,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await fetch(targetUrl, {
const upstream = await safeFetch(targetUrl, {
method: "POST",
headers: {
"content-type":
+2 -1
View File
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import packageJson from "../../../../../package.json";
export const dynamic = "force-dynamic";
@@ -80,7 +81,7 @@ export const GET = apiHandler(async () => {
let release: GithubReleaseShape | null = null;
const callStart = Date.now();
try {
const res = await fetch(RELEASES_URL, {
const res = await safeFetch(RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
// GitHub anon rate limit is 60/hr per IP — tight but not zero.
// We don't pass a token because the repo is public and the route