import { env } from "cloudflare:workers"; import { assertPublicHttpsUrl } from "../git/relay"; import { assertResolvesPublic } from "../net/resolve-guard"; import { HttpError, badRequest } from "../errors"; import { isLocalDev } from "../deployment"; const OUTBOUND_TIMEOUT_MS = 10_000; const MAX_REQUEST_BYTES = 256 * 1024; const MAX_RESPONSE_BYTES = 64 * 1024; const HTTP_OK_MIN = 200; const HTTP_OK_MAX = 300; const HTTP_REDIRECT_MIN = 300; const HTTP_REDIRECT_MAX = 400; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); /** `permanent` failures (bad config, rejected URL, most 4xx) are not worth retrying. */ export type OutboundResult = { status: number | null; body: string; error: string | null; durationMs: number; permanent?: boolean; }; /** INTEGRATIONS_ALLOW_LOOPBACK only counts on a loopback forge, like legacy webhooks (DEPLOY-P6). */ export function loopbackSinksAllowed(): boolean { return env.INTEGRATIONS_ALLOW_LOOPBACK === "true" && isLocalDev(); } export function isAllowedLoopback(url: URL): boolean { return loopbackSinksAllowed() && LOOPBACK_HOSTS.has(url.hostname); } /** * Every integration URL must be public https (no IP literals, internal suffixes or our own zone). Local * development can opt into loopback sinks with INTEGRATIONS_ALLOW_LOOPBACK, which production never sets. * Legacy repo webhooks may use plain http only when the operator sets WEBHOOKS_ALLOW_HTTP; the host rules * still apply. */ export function assertOutboundUrl(raw: string, allowLoopback = loopbackSinksAllowed(), allowHttp = false): URL { if (allowLoopback) { try { const url = new URL(raw); if ((url.protocol === "http:" || url.protocol === "https:") && LOOPBACK_HOSTS.has(url.hostname)) return url; } catch { throw badRequest("Invalid URL"); } } if (allowHttp && env.WEBHOOKS_ALLOW_HTTP === "true") { let url: URL; try { url = new URL(raw); } catch { throw badRequest("Invalid URL"); } if (url.protocol === "http:") { const probe = new URL(url.toString()); probe.protocol = "https:"; assertPublicHttpsUrl(probe.toString()); return url; } } return assertPublicHttpsUrl(raw); } async function readCapped(response: Response, maxBytes: number): Promise { if (!response.body) return ""; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (size < maxBytes) { const { value, done } = await reader.read(); if (done) break; chunks.push(value); size += value.byteLength; } await reader.cancel().catch(() => undefined); const merged = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(merged.slice(0, maxBytes)); } /** SSRF-guarded POST/GET: validated URL, no redirects, a timeout, and capped request and response sizes. */ export async function outboundFetch( url: string, init: { method?: string; headers?: Record; body?: string; allowHttp?: boolean }, maxResponseBytes = MAX_RESPONSE_BYTES, ): Promise { const started = Date.now(); try { const target = assertOutboundUrl(url, undefined, init.allowHttp === true); if (!isAllowedLoopback(target)) await assertResolvesPublic(target); if (init.body && new TextEncoder().encode(init.body).byteLength > MAX_REQUEST_BYTES) { throw badRequest("Payload is too large"); } const response = await fetch(target.toString(), { method: init.method ?? "POST", headers: { "user-agent": "gitfor-integrations", ...init.headers }, body: init.body, redirect: "manual", signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), }); if (response.status >= HTTP_REDIRECT_MIN && response.status < HTTP_REDIRECT_MAX) { await response.body?.cancel(); return { status: response.status, body: "", error: "Redirects are not followed", durationMs: Date.now() - started, permanent: true, }; } return { status: response.status, body: await readCapped(response, maxResponseBytes), error: null, durationMs: Date.now() - started, }; } catch (caught) { const error = caught instanceof Error ? caught.message : String(caught); return { status: null, body: "", error, durationMs: Date.now() - started, permanent: caught instanceof HttpError && caught.status < HTTP_SERVER_ERROR_MIN, }; } } const RETRYABLE_CLIENT_ERRORS = new Set([408, 409, 425, 429]); const HTTP_CLIENT_ERROR_MIN = 400; const HTTP_SERVER_ERROR_MIN = 500; export function isPermanentFailure(result: OutboundResult): boolean { if (result.permanent) return true; const status = result.status; return ( status !== null && status >= HTTP_CLIENT_ERROR_MIN && status < HTTP_SERVER_ERROR_MIN && !RETRYABLE_CLIENT_ERRORS.has(status) ); } export function isSuccess(result: OutboundResult): boolean { return result.error === null && result.status !== null && result.status >= HTTP_OK_MIN && result.status < HTTP_OK_MAX; }