mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (#10416)
* fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs
The subscription fetch guard (fetchGuard.ts) unconditionally blocked all
loopback/private IP ranges as SSRF protection, but the same feature already
permits loopback for the routing half (coreEndpoint.ts's
ALLOWED_LOCAL_CORE_HOSTS) — so an operator could route traffic through a
loopback core but could not fetch a proxy list from a loopback HTTP server.
Make the fetch guard local-first by reusing the existing
areLocalProviderUrlsAllowed() policy (default ON) from
outboundUrlGuardPolicy.ts: loopback/private hosts are now allowed as fetch
targets by default, while cloud-metadata/link-local (169.254.0.0/16, incl.
169.254.169.254 IMDS) and the unspecified address stay blocked
unconditionally, mirroring the provider-validation guard's "block-metadata"
mode. Callers that want the old strict behavior can pass
{ allowLocal: false }.
Closes #10158.
* fix(proxy-subscriptions): unwrap IPv4-mapped IPv6 + full fe80::/10 range (#10416)
The #10158 SSRF guard left two gaps on the IPv6 side: an IPv4-mapped IPv6
literal (::ffff:a.b.c.d) skipped IPv4 range checking entirely, and the
link-local check only matched strings literally prefixed with "fe80"
instead of the full fe80::/10 range (fe80:: - febf:ffff::), so fe90::,
febf:ffff::, etc. were wrongly allowed through.
isIpv6Blocked() now unwraps mapped IPv4 addresses (both the dotted-quad
and WHATWG-normalized hex-group forms) and re-checks them against the
IPv4 rules, and link-local detection parses the first hex group's numeric
value against the 0xfe80-0xfebf range instead of a string prefix.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
548316a2c4
commit
514573b1f6
@@ -3,30 +3,63 @@
|
||||
*
|
||||
* The subscription URL is fetched server-side (see `subscriptionService
|
||||
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
|
||||
* subscription link — could point OmniRoute at internal services or cloud
|
||||
* metadata (SSRF). Only http/https to non-internal hosts are allowed:
|
||||
* loopback / private / link-local (incl. 169.254.0.0/16 cloud metadata) /
|
||||
* unspecified addresses are blocked.
|
||||
* subscription link — could point OmniRoute at cloud metadata (SSRF). Only
|
||||
* http/https to non-metadata hosts are allowed.
|
||||
*
|
||||
* Local-first (#10158): OmniRoute already lets an operator route ALL traffic
|
||||
* through a loopback core (`coreEndpoint.ts` allows `127.0.0.1`/`::1`/
|
||||
* `localhost`), so a subscription fetch target on loopback/private ranges is
|
||||
* ALLOWED by default (`allowLocal: true`, matching the local-first default of
|
||||
* `areLocalProviderUrlsAllowed()` in `src/shared/network/outboundUrlGuardPolicy
|
||||
* .ts`) — mirroring that policy's "block-metadata" mode. Cloud-metadata /
|
||||
* link-local (`169.254.0.0/16`, incl. `169.254.169.254` IMDS) and the
|
||||
* unspecified address (`0.0.0.0/8`) are blocked UNCONDITIONALLY regardless of
|
||||
* `allowLocal`, since they have no legitimate subscription-source use case.
|
||||
* Callers that want the old strict (public-only) behavior pass
|
||||
* `{ allowLocal: false }`.
|
||||
*
|
||||
* Hostname resolution is re-checked at fetch time (also using the IP-range
|
||||
* helpers here) so a hostname that resolves to an internal address is still
|
||||
* refused. Splitting the logic into pure functions keeps it unit-testable
|
||||
* without DNS / the full stack.
|
||||
* without DNS / the full stack. No `@/`-aliased or DB-backed imports here —
|
||||
* the `allowLocal` policy decision is made by the caller (subscriptionService,
|
||||
* which is already DB-backed) and passed in as a plain boolean.
|
||||
*
|
||||
* IPv6 hardening (#10416): IPv4-mapped IPv6 literals (`::ffff:a.b.c.d`) are
|
||||
* unwrapped and re-checked against the IPv4 ranges, so a mapped IMDS/
|
||||
* loopback/private address can't bypass the guard. Link-local detection
|
||||
* covers the FULL `fe80::/10` range (`fe80::`-`febf:ffff:…`), not just
|
||||
* strings literally prefixed with `fe80`.
|
||||
*/
|
||||
|
||||
/** Only these URL schemes may be used to *fetch* a subscription. */
|
||||
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
|
||||
|
||||
// Blocked IPv4 ranges (base, mask) as 32-bit ints.
|
||||
const BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
||||
// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud
|
||||
// credential pivot; never a legitimate subscription source.
|
||||
const ALWAYS_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x00000000, 0xff000000], // 0.0.0.0/8 unspecified
|
||||
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (incl. cloud metadata IMDS)
|
||||
];
|
||||
|
||||
// Blocked only when `allowLocal` is false (strict/public-only mode).
|
||||
const LOCAL_ONLY_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x7f000000, 0xff000000], // 127.0.0.0/8 loopback
|
||||
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
|
||||
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
|
||||
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 private
|
||||
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (cloud metadata)
|
||||
];
|
||||
|
||||
export interface FetchGuardOptions {
|
||||
/**
|
||||
* When true (default), loopback/private hosts are allowed as fetch targets
|
||||
* ("local-first" — matches `areLocalProviderUrlsAllowed()`'s default). Cloud
|
||||
* metadata / link-local is blocked unconditionally either way. Pass `false`
|
||||
* to restore the strict public-only behavior.
|
||||
*/
|
||||
allowLocal?: boolean;
|
||||
}
|
||||
|
||||
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
|
||||
export function isIpv4Literal(host: string): boolean {
|
||||
@@ -44,20 +77,75 @@ export function ipv4ToLong(host: string): number | null {
|
||||
return (parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]) >>> 0;
|
||||
}
|
||||
|
||||
export function isIpv4Blocked(ip: string): boolean {
|
||||
export function isIpv4Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
|
||||
const allowLocal = opts.allowLocal ?? true;
|
||||
const n = ipv4ToLong(ip);
|
||||
if (n === null) return false;
|
||||
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
|
||||
// comparing so masked results with the high bit set aren't negative.
|
||||
return BLOCKED_IPV4.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
|
||||
const ranges = allowLocal ? ALWAYS_BLOCKED_IPV4 : [...ALWAYS_BLOCKED_IPV4, ...LOCAL_ONLY_BLOCKED_IPV4];
|
||||
return ranges.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
|
||||
}
|
||||
|
||||
/** Blocked IPv6 addresses: loopback, unspecified, link-local, ULA. */
|
||||
export function isIpv6Blocked(ip: string): boolean {
|
||||
// IPv4-mapped IPv6, dotted-quad tail: "::ffff:a.b.c.d" or its fully-expanded
|
||||
// "0:0:0:0:0:ffff:a.b.c.d" form. This is how the literal is typically
|
||||
// *written* (e.g. by a caller invoking `isIpv6Blocked` directly).
|
||||
const IPV4_MAPPED_DOTTED_RE =
|
||||
/^(?:::ffff:|0:0:0:0:0:ffff:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
|
||||
|
||||
// IPv4-mapped IPv6, hex-group tail: "::ffff:HHHH:HHHH". This is how the
|
||||
// WHATWG `URL` parser NORMALIZES a dotted-quad mapped literal (e.g.
|
||||
// `::ffff:169.254.169.254` becomes `::ffff:a9fe:a9fe`), so a URL-derived
|
||||
// hostname needs this form recognized too or the guard silently sees a
|
||||
// hostname it never resolves the mapped address for.
|
||||
const IPV4_MAPPED_HEX_RE = /^(?:::ffff:|0:0:0:0:0:ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
|
||||
|
||||
/** Extracts the mapped IPv4 address from an IPv4-mapped IPv6 literal, or null. */
|
||||
export function extractIpv4MappedAddress(ip: string): string | null {
|
||||
const dotted = IPV4_MAPPED_DOTTED_RE.exec(ip);
|
||||
if (dotted) return dotted[1];
|
||||
const hex = IPV4_MAPPED_HEX_RE.exec(ip);
|
||||
if (!hex) return null;
|
||||
const hi = parseInt(hex[1], 16);
|
||||
const lo = parseInt(hex[2], 16);
|
||||
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
||||
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `ip`'s first 16-bit hex group falls in `fe80`-`febf` — the full
|
||||
* `fe80::/10` link-local range (top 10 bits `1111111010`, i.e. the low 6 bits
|
||||
* of the first group are free). A `.startsWith("fe80")` check only matches
|
||||
* the single `fe80` group and misses the rest of the range (e.g. `fe90::`,
|
||||
* `febf:ffff::`); it would also wrongly match hostnames like `fe80abc::`,
|
||||
* which this exact-group parse avoids. `fec0::/10` (deprecated site-local)
|
||||
* is intentionally excluded — it is outside `fe80::/10`.
|
||||
*/
|
||||
function isIpv6LinkLocal(ip: string): boolean {
|
||||
if (ip.startsWith("::")) return false; // first group is 0 — never link-local
|
||||
const idx = ip.indexOf(":");
|
||||
if (idx <= 0 || idx > 4) return false;
|
||||
const group = ip.slice(0, idx);
|
||||
const n = parseInt(group, 16);
|
||||
if (Number.isNaN(n)) return false;
|
||||
return n >= 0xfe80 && n <= 0xfebf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked IPv6 addresses: unspecified/link-local always; loopback/ULA only
|
||||
* when strict. IPv4-mapped literals (`::ffff:a.b.c.d`) are unwrapped and
|
||||
* re-checked against the IPv4 rules so a mapped IMDS/loopback/private
|
||||
* address can't bypass the guard.
|
||||
*/
|
||||
export function isIpv6Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
|
||||
const allowLocal = opts.allowLocal ?? true;
|
||||
const h = ip.toLowerCase();
|
||||
if (h === "::") return true; // unspecified — always blocked
|
||||
const mapped = extractIpv4MappedAddress(h);
|
||||
if (mapped !== null) return isIpv4Blocked(mapped, opts);
|
||||
if (isIpv6LinkLocal(h)) return true; // fe80::/10 — always blocked
|
||||
if (allowLocal) return false;
|
||||
if (h === "::1") return true; // loopback
|
||||
if (h === "::") return true; // unspecified
|
||||
if (h.startsWith("fe80")) return true; // link-local
|
||||
if (h.startsWith("fc") || h.startsWith("fd")) return true; // unique local
|
||||
return false;
|
||||
}
|
||||
@@ -65,8 +153,12 @@ export function isIpv6Blocked(ip: string): boolean {
|
||||
/** Whether `host` is an IP literal (v4 or v6). Hostnames return false. */
|
||||
export function isIpLiteral(host: string): boolean {
|
||||
if (isIpv4Literal(host)) return true;
|
||||
// IPv6 literals contain ":" and consist only of hex digits + ":".
|
||||
return host.includes(":") && /^([0-9a-fA-F:]+)$/.test(host);
|
||||
if (!host.includes(":")) return false;
|
||||
// Plain IPv6 literal (hex groups + colons)...
|
||||
if (/^([0-9a-fA-F:]+)$/.test(host)) return true;
|
||||
// ...or an IPv4-mapped IPv6 literal, which ends in a dotted-quad tail
|
||||
// (e.g. "::ffff:169.254.169.254") and so isn't pure hex+colons.
|
||||
return /^[0-9a-fA-F:]+:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,20 +170,22 @@ export function isIpLiteral(host: string): boolean {
|
||||
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
|
||||
*/
|
||||
export function isAnyResolvedAddressBlocked(
|
||||
addrs: ReadonlyArray<{ address: string; family?: number }>
|
||||
addrs: ReadonlyArray<{ address: string; family?: number }>,
|
||||
opts: FetchGuardOptions = {}
|
||||
): boolean {
|
||||
return addrs.some(({ address, family }) => {
|
||||
const fam = family === 6 ? 6 : 4;
|
||||
return fam === 6 ? isIpv6Blocked(address) : isIpv4Blocked(address);
|
||||
return fam === 6 ? isIpv6Blocked(address, opts) : isIpv4Blocked(address, opts);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural check (no DNS). True only if the scheme is allowed AND, when the
|
||||
* host is an IP literal, it is not in a blocked range. Hostnames pass the
|
||||
* structural check — they are resolved and re-checked at fetch time.
|
||||
* host is an IP literal, it is not in a blocked range for the given
|
||||
* `allowLocal` mode. Hostnames pass the structural check — they are resolved
|
||||
* and re-checked at fetch time.
|
||||
*/
|
||||
export function isSubscriptionFetchUrlAllowed(url: string): boolean {
|
||||
export function isSubscriptionFetchUrlAllowed(url: string, opts: FetchGuardOptions = {}): boolean {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
@@ -104,8 +198,8 @@ export function isSubscriptionFetchUrlAllowed(url: string): boolean {
|
||||
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
||||
if (host === "") return false;
|
||||
if (isIpLiteral(host)) {
|
||||
if (isIpv4Literal(host)) return !isIpv4Blocked(host);
|
||||
return !isIpv6Blocked(host);
|
||||
if (isIpv4Literal(host)) return !isIpv4Blocked(host, opts);
|
||||
return !isIpv6Blocked(host, opts);
|
||||
}
|
||||
return true; // hostname: resolved + checked at fetch time
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
isSubscriptionFetchUrlAllowed,
|
||||
isIpLiteral,
|
||||
isAnyResolvedAddressBlocked,
|
||||
type FetchGuardOptions,
|
||||
} from "./fetchGuard";
|
||||
import { areLocalProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { withRetry } from "./fetchRetry";
|
||||
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
|
||||
|
||||
@@ -280,13 +282,20 @@ export async function deleteSubscription(id: string): Promise<boolean> {
|
||||
// ───────────────────────────── Sync + apply ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Refuse to fetch a subscription URL unless it is http/https to a non-internal
|
||||
* Refuse to fetch a subscription URL unless it is http/https to an allowed
|
||||
* host. IP literals are checked structurally; hostnames are resolved and the
|
||||
* resolved addresses are re-checked (fail closed on resolution errors). This
|
||||
* blocks SSRF to internal services / cloud metadata (169.254.169.254).
|
||||
* resolved addresses are re-checked (fail closed on resolution errors).
|
||||
*
|
||||
* Local-first (#10158): loopback/private fetch targets are ALLOWED when
|
||||
* `areLocalProviderUrlsAllowed()` is on (default ON — same local-first policy
|
||||
* already used for provider validation, and consistent with
|
||||
* `coreEndpoint.ts` already permitting a loopback routing core). Cloud
|
||||
* metadata / link-local (169.254.0.0/16, incl. 169.254.169.254 IMDS) is
|
||||
* blocked UNCONDITIONALLY regardless of that flag.
|
||||
*/
|
||||
async function assertSafeFetchTarget(url: string): Promise<void> {
|
||||
if (!isSubscriptionFetchUrlAllowed(url)) {
|
||||
const guardOpts: FetchGuardOptions = { allowLocal: areLocalProviderUrlsAllowed() };
|
||||
if (!isSubscriptionFetchUrlAllowed(url, guardOpts)) {
|
||||
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
|
||||
}
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
@@ -299,7 +308,7 @@ async function assertSafeFetchTarget(url: string): Promise<void> {
|
||||
try {
|
||||
const dns = await import("node:dns");
|
||||
const addrs = await dns.promises.lookup(bare, { all: true });
|
||||
if (isAnyResolvedAddressBlocked(addrs)) {
|
||||
if (isAnyResolvedAddressBlocked(addrs, guardOpts)) {
|
||||
throw new Error("Subscription host resolves to a blocked (internal) address");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user