mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
bf6dfb8f6d
commit
425503e095
24 files changed
+480
-148
No files matched your search
@@ -83,6 +83,7 @@
|
||||
"sax": "^1.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"undici": "^7.25.0",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
|
||||
Generated
+3
@@ -110,6 +110,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
undici:
|
||||
specifier: ^7.25.0
|
||||
version: 7.25.0
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { safeFetch, SafeFetchError } from "../safe-fetch";
|
||||
|
||||
describe("safeFetch", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("defaults init.redirect to 'manual'", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
await safeFetch("https://example.com");
|
||||
|
||||
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("flips to 'follow' when opts.followRedirects is true", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
await safeFetch("https://example.com", {}, { followRedirects: true });
|
||||
|
||||
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.redirect).toBe("follow");
|
||||
});
|
||||
|
||||
it("preserves an explicit init.redirect override", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
await safeFetch("https://example.com", { redirect: "error" });
|
||||
|
||||
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.redirect).toBe("error");
|
||||
});
|
||||
|
||||
it("aborts on timeout (override to a tight window for the test)", async () => {
|
||||
// Use a real, very short timeout. `AbortSignal.timeout` is a platform
|
||||
// primitive that vi.useFakeTimers cannot reliably advance, so the
|
||||
// assertion runs against actual wall-clock — but 25 ms is short
|
||||
// enough that the suite stays fast.
|
||||
fetchSpy.mockImplementationOnce(
|
||||
(_url: unknown, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(signal.reason);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
safeFetch("https://example.com", {}, { timeoutMs: 25 }),
|
||||
).rejects.toMatchObject({ kind: "timeout" });
|
||||
});
|
||||
|
||||
it("respects a caller-supplied signal via AbortSignal.any", async () => {
|
||||
const controller = new AbortController();
|
||||
fetchSpy.mockImplementationOnce(
|
||||
(_url: unknown, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(signal.reason);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = safeFetch("https://example.com", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(SafeFetchError);
|
||||
await expect(pending).rejects.toMatchObject({ kind: "timeout" });
|
||||
});
|
||||
|
||||
it("rejects a private host when requirePublicHost is true", async () => {
|
||||
await expect(
|
||||
safeFetch(
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
{},
|
||||
{ requirePublicHost: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
kind: "private_host",
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an RFC1918 host when requirePublicHost is true", async () => {
|
||||
await expect(
|
||||
safeFetch("http://10.0.0.5/admin", {}, { requirePublicHost: true }),
|
||||
).rejects.toMatchObject({
|
||||
kind: "private_host",
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a public host through with requirePublicHost", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
const res = await safeFetch(
|
||||
"https://example.com/api",
|
||||
{},
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("wraps a native network error as kind: 'network'", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError("network down"));
|
||||
|
||||
await expect(safeFetch("https://example.com")).rejects.toMatchObject({
|
||||
kind: "network",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a URL object as the target", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
await safeFetch(new URL("https://example.com/path"));
|
||||
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/path");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
|
||||
describe("LocalOpenAICompatibleClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// These tests cover the operator-opt-in path (LM Studio / Ollama on
|
||||
// an RFC1918 host). The safeFetch wrapper enforces the SSRF guard
|
||||
// unless the operator explicitly accepts a private host.
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("targets the configured baseUrl + /chat/completions", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
@@ -32,6 +32,14 @@ function assertCompletionResult(result: CompletionResult): void {
|
||||
describe("AIProvider contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// The local-provider contract test points at a localhost OpenAI-
|
||||
// compatible server (Ollama / LM Studio). safeFetch enforces the
|
||||
// SSRF guard unless the operator explicitly opts in.
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("MockAIProvider — default response satisfies CompletionResult shape", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface AnthropicClientConfig {
|
||||
@@ -35,23 +36,26 @@ export class AnthropicClient implements AIProvider {
|
||||
);
|
||||
const url = `${baseUrl}/messages`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.config.apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.config.apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
system: params.systemPrompt,
|
||||
messages: [{ role: "user", content: wrapForJson(params.userPrompt) }],
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
system: params.systemPrompt,
|
||||
messages: [{ role: "user", content: wrapForJson(params.userPrompt) }],
|
||||
}),
|
||||
// 60 s ceiling — see openai-client.ts for the rationale.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
|
||||
|
||||
+31
-17
@@ -1,3 +1,4 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface LocalClientConfig {
|
||||
@@ -38,23 +39,36 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
|
||||
const userPrompt = `Return strict JSON only, no markdown, no commentary.\n\n${params.userPrompt}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
}),
|
||||
// 60 s ceiling — a misbehaving local endpoint (Ollama on a stalled
|
||||
// GPU, a tar-pit proxy) shouldn't be able to pin a worker
|
||||
// indefinitely. Real local completions land well inside this.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
// safeFetch defaults: no redirect-follow (the local endpoint is the
|
||||
// most exploitable on this surface — a user-controlled baseUrl that
|
||||
// 302s to 169.254.169.254 would otherwise leak the bearer on the
|
||||
// redirected hop into the visible error envelope via `bodyExcerpt`)
|
||||
// and an explicit 60 s ceiling so a stalled GPU or tar-pit proxy
|
||||
// can't pin a worker indefinitely. `requirePublicHost` gates the
|
||||
// baseUrl against the input-time SSRF allowlist; the dispatcher
|
||||
// pin (issue #217) extends the same flag with a connect-time
|
||||
// resolved-IP check to also defeat DNS rebinding. Operators who
|
||||
// legitimately point at a self-hosted Ollama / LM Studio on an
|
||||
// RFC1918 address opt out via `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`
|
||||
// (also enforced at write-time in /api/user/ai-provider).
|
||||
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client structured-error pattern. Local endpoints
|
||||
|
||||
+23
-20
@@ -1,3 +1,4 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface OpenAIClientConfig {
|
||||
@@ -30,27 +31,29 @@ export class OpenAIClient implements AIProvider {
|
||||
): Promise<CompletionResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: params.userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: params.userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
// Cap any single completion at 60 s so a tar-pit upstream cannot
|
||||
// pin a worker indefinitely. Real completions land well inside this
|
||||
// budget — see `getEvent()?.addExternalCall` timings in production.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
// 60 s ceiling so a tar-pit upstream cannot pin a worker
|
||||
// indefinitely. Real completions land well inside this budget.
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
// Pull as much of the body as we can so upstream incidents (OpenAI 5xx,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
export interface FeedbackInput {
|
||||
category: string;
|
||||
@@ -135,7 +136,7 @@ export async function publishFeedbackToGithub(
|
||||
|
||||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||||
|
||||
const res = await fetch(
|
||||
const res = await safeFetch(
|
||||
`https://api.github.com/repos/${config.repo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
+17
-13
@@ -65,6 +65,7 @@ import type {
|
||||
CityResponse,
|
||||
} from "mmdb-lib/lib/reader/response";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
interface IpwhoIsResponse {
|
||||
success?: boolean;
|
||||
@@ -284,20 +285,23 @@ async function readUtf8Json(res: Response): Promise<unknown> {
|
||||
async function lookupIpLocationOnline(ip: string): Promise<string | null> {
|
||||
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
|
||||
try {
|
||||
const res = await fetch(buildLookupUrl(ip), {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
headers: {
|
||||
// ipwho.is and ip-api both honour Accept-Language for the
|
||||
// city field. Without the hint, ip-api falls back to the
|
||||
// English ASCII fold ("Nuremberg" instead of "Nürnberg").
|
||||
// German first because the user-base is DACH-skewed; English
|
||||
// as a graceful fallback for cities that don't have a German
|
||||
// name. Quality 0.5 on the en fallback keeps providers from
|
||||
// tie-breaking the wrong way.
|
||||
"Accept-Language": "de, en;q=0.5",
|
||||
Accept: "application/json",
|
||||
const res = await safeFetch(
|
||||
buildLookupUrl(ip),
|
||||
{
|
||||
headers: {
|
||||
// ipwho.is and ip-api both honour Accept-Language for the
|
||||
// city field. Without the hint, ip-api falls back to the
|
||||
// English ASCII fold ("Nuremberg" instead of "Nürnberg").
|
||||
// German first because the user-base is DACH-skewed; English
|
||||
// as a graceful fallback for cities that don't have a German
|
||||
// name. Quality 0.5 on the en fallback keeps providers from
|
||||
// tie-breaking the wrong way.
|
||||
"Accept-Language": "de, en;q=0.5",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
{ timeoutMs: 3_000 },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await readUtf8Json(res)) as GeoResponse;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WideEvent } from "./types";
|
||||
import { getLoggingConfig } from "./config";
|
||||
import { shouldEmit } from "./sampler";
|
||||
import { appendLogEvent } from "./in-memory-buffer";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
/** Event auf stdout als einzelne JSON-Zeile schreiben */
|
||||
function emitToStdout(event: WideEvent): void {
|
||||
@@ -64,12 +65,15 @@ async function flushLokiBuffer(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${config.lokiEndpoint}/loki/api/v1/push`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ streams }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
await safeFetch(
|
||||
`${config.lokiEndpoint}/loki/api/v1/push`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ streams }),
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
} catch {
|
||||
// Events gehen verloren — akzeptabel fuer Logging
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
interface ParsedGlitchtipDsn {
|
||||
dsn: string;
|
||||
publicKey: string;
|
||||
@@ -121,7 +123,7 @@ async function tryEnvelope(
|
||||
config: ParsedGlitchtipDsn,
|
||||
body: string,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const response = await fetch(config.envelopeUrl, {
|
||||
const response = await safeFetch(config.envelopeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
@@ -150,7 +152,7 @@ async function tryStoreQuery(
|
||||
target.searchParams.set("sentry_version", "7");
|
||||
target.searchParams.set("sentry_client", "healthlog/1.0");
|
||||
|
||||
const response = await fetch(target, {
|
||||
const response = await safeFetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
@@ -175,7 +177,7 @@ async function tryStoreHeader(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const sentryAuth = `Sentry sentry_version=7, sentry_key=${config.publicKey}, sentry_client=healthlog/1.0`;
|
||||
const response = await fetch(config.storeUrl, {
|
||||
const response = await safeFetch(config.storeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
|
||||
+12
-15
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.50 — reverse-sync push: HealthLog → MoodLog.
|
||||
@@ -110,24 +111,22 @@ export async function pushMoodEntriesToMoodLog(
|
||||
})),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const start = performance.now();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
response = await safeFetch(
|
||||
url.toString(),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "moodlog",
|
||||
@@ -138,8 +137,6 @@ export async function pushMoodEntriesToMoodLog(
|
||||
`moodlog push failed for ${userId} (fetch): ${message}`,
|
||||
);
|
||||
return { pushed: 0, skipped: candidates.length, status: "failed" };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
getEvent()?.addExternalCall({
|
||||
|
||||
+10
-15
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
isReauthRequired,
|
||||
recordSyncFailure,
|
||||
@@ -87,23 +88,19 @@ export async function syncMoodLogEntries(
|
||||
url.searchParams.set("from", from);
|
||||
url.searchParams.set("to", to);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = opts?.fullSync ? 60000 : 15000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// safeFetch keeps the no-redirect-follow guard (the apiKey would
|
||||
// otherwise leak on an attacker-controlled 302 hop) and adds a hard
|
||||
// upper bound via AbortSignal.timeout.
|
||||
const timeoutMs = opts?.fullSync ? 60_000 : 15_000;
|
||||
let response: Response;
|
||||
const fetchStart = performance.now();
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
// SSRF defence-in-depth: do NOT follow redirects. A public
|
||||
// host that 302s to an RFC1918 target would otherwise leak
|
||||
// the apiKey to the internal hop.
|
||||
redirect: "manual",
|
||||
});
|
||||
response = await safeFetch(
|
||||
url.toString(),
|
||||
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||
{ timeoutMs },
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "moodlog",
|
||||
method: "syncMoodLogEntries",
|
||||
@@ -118,8 +115,6 @@ export async function syncMoodLogEntries(
|
||||
errorCode: "fetch_failed",
|
||||
});
|
||||
return 0;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
getEvent()?.addExternalCall({
|
||||
service: "moodlog",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
/**
|
||||
* Send notification via ntfy (simple HTTP POST).
|
||||
@@ -38,12 +39,15 @@ export async function sendViaNtfy(
|
||||
// Strip HTML tags for ntfy (plain text only)
|
||||
const body = payload.message.replace(/<[^>]*>/g, "");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
|
||||
getEvent()?.addExternalCall({
|
||||
service: "ntfy",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
|
||||
try {
|
||||
return await fetch(target, { ...init, redirect, signal });
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
* Uses the HTTP API directly — no library needed.
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
interface TelegramResponse {
|
||||
ok: boolean;
|
||||
@@ -31,14 +32,14 @@ async function telegramApiRequest(
|
||||
): Promise<TelegramResponse> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const res = await fetch(
|
||||
const res = await safeFetch(
|
||||
`https://api.telegram.org/bot${botToken}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
const data = (await res.json()) as TelegramResponse;
|
||||
getEvent()?.addExternalCall({
|
||||
|
||||
Reference in new issue
Block a user