153 lines · 5.1 KB
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 {