| import { isIP } from "node:net";
|
| import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
|
|
|
| const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
|
| export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL";
|
| export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
|
|
|
| export type OutboundUrlGuardMode = "none" | "public-only";
|
| export type OutboundUrlGuardErrorCode = "OUTBOUND_URL_GUARD_BLOCKED" | "OUTBOUND_URL_INVALID";
|
|
|
| type OutboundUrlGuardErrorInit = {
|
| code: OutboundUrlGuardErrorCode;
|
| url: string;
|
| hostname?: string | null;
|
| };
|
|
|
| export class OutboundUrlGuardError extends Error {
|
| code: OutboundUrlGuardErrorCode;
|
| url: string;
|
| hostname?: string | null;
|
|
|
| constructor(message: string, init: OutboundUrlGuardErrorInit) {
|
| super(message);
|
| this.name = "OutboundUrlGuardError";
|
| this.code = init.code;
|
| this.url = init.url;
|
| this.hostname = init.hostname ?? null;
|
| }
|
| }
|
|
|
| function normalizeHost(hostname: string) {
|
| const normalized = hostname.trim().toLowerCase();
|
| if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
| return normalized.slice(1, -1);
|
| }
|
| return normalized;
|
| }
|
|
|
| export function isPrivateHost(hostname: string) {
|
| const normalized = normalizeHost(hostname);
|
| if (!normalized) return true;
|
|
|
| if (
|
| normalized === "localhost" ||
|
| normalized === "0.0.0.0" ||
|
| normalized === "127.0.0.1" ||
|
| normalized === "::1" ||
|
| normalized.endsWith(".localhost") ||
|
| normalized.endsWith(".local") ||
|
|
|
|
|
|
|
| normalized.endsWith(".internal") ||
|
| normalized.startsWith("::ffff:")
|
| ) {
|
| return true;
|
| }
|
|
|
| if (isIP(normalized) === 4) {
|
| const octets = normalized.split(".").map((segment) => parseInt(segment, 10));
|
| const [a, b] = octets;
|
|
|
| if (a === 0 || a === 10 || a === 127) return true;
|
| if (a === 169 && b === 254) return true;
|
| if (a === 192 && b === 168) return true;
|
| if (a === 172 && b >= 16 && b <= 31) return true;
|
| if (a === 100 && b >= 64 && b <= 127) return true;
|
| return false;
|
| }
|
|
|
| if (isIP(normalized) === 6) {
|
| return (
|
| normalized === "::1" ||
|
| normalized.startsWith("fc") ||
|
| normalized.startsWith("fd") ||
|
| normalized.startsWith("fe80:")
|
| );
|
| }
|
|
|
| return false;
|
| }
|
|
|
| const CLOUD_METADATA_HOSTNAMES = new Set([
|
| "169.254.169.254",
|
| "metadata.google.internal",
|
| "metadata.goog",
|
| "100.100.100.200",
|
| "fd00:ec2::254",
|
| ]);
|
|
|
| |
| |
| |
| |
|
|
| export function isCloudMetadataHost(hostname: string): boolean {
|
| const host = normalizeHost(hostname);
|
| if (!host) return false;
|
| if (CLOUD_METADATA_HOSTNAMES.has(host)) return true;
|
| if (host.startsWith("169.254.")) return true;
|
| return false;
|
| }
|
|
|
| export function parseOutboundUrl(input: string | URL) {
|
| let url: URL;
|
| try {
|
| url = input instanceof URL ? input : new URL(String(input));
|
| } catch {
|
| throw new OutboundUrlGuardError(`Invalid outbound URL: ${String(input)}`, {
|
| code: "OUTBOUND_URL_INVALID",
|
| url: String(input),
|
| });
|
| }
|
|
|
| if (url.protocol !== "http:" && url.protocol !== "https:") {
|
| throw new OutboundUrlGuardError(`Invalid outbound URL protocol for ${url.toString()}`, {
|
| code: "OUTBOUND_URL_INVALID",
|
| url: url.toString(),
|
| hostname: url.hostname || null,
|
| });
|
| }
|
|
|
| if (url.username || url.password) {
|
| throw new OutboundUrlGuardError("Blocked outbound URL with embedded credentials", {
|
| code: "OUTBOUND_URL_GUARD_BLOCKED",
|
| url: url.toString(),
|
| hostname: url.hostname || null,
|
| });
|
| }
|
|
|
| return url;
|
| }
|
|
|
| export function parseAndValidatePublicUrl(input: string | URL) {
|
| const url = parseOutboundUrl(input);
|
|
|
| if (isPrivateHost(url.hostname)) {
|
| throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
| code: "OUTBOUND_URL_GUARD_BLOCKED",
|
| url: url.toString(),
|
| hostname: url.hostname || null,
|
| });
|
| }
|
|
|
| return url;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| export function parseAndValidateWebhookUrl(input: string | URL) {
|
| const url = parseOutboundUrl(input);
|
|
|
|
|
|
|
| if (isCloudMetadataHost(url.hostname)) {
|
| throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
| code: "OUTBOUND_URL_GUARD_BLOCKED",
|
| url: url.toString(),
|
| hostname: url.hostname || null,
|
| });
|
| }
|
|
|
| if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) {
|
| throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
| code: "OUTBOUND_URL_GUARD_BLOCKED",
|
| url: url.toString(),
|
| hostname: url.hostname || null,
|
| });
|
| }
|
|
|
| return url;
|
| }
|
|
|
| function isTrueValue(raw: unknown): boolean {
|
| if (typeof raw !== "string") return false;
|
| return TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
|
| }
|
|
|
| export function arePrivateProviderUrlsAllowed() {
|
|
|
|
|
|
|
|
|
|
|
| try {
|
| const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV);
|
| if (isTrueValue(dbValue)) return true;
|
| } catch {
|
|
|
| }
|
|
|
|
|
| if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true;
|
|
|
|
|
|
|
| const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
|
| if (
|
| typeof legacyValue === "string" &&
|
| ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase())
|
| ) {
|
| return true;
|
| }
|
|
|
| return false;
|
| }
|
|
|
| export function getProviderOutboundGuard(): OutboundUrlGuardMode {
|
| return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
|
| }
|
|
|