mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 13:14:56 +03:00
Compare commits
2 Commits
refactor/e
...
fix/10158-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ab289b7a | ||
|
|
e7718b0423 |
1
changelog.d/fixes/10158-local-proxy-subscription.md
Normal file
1
changelog.d/fixes/10158-local-proxy-subscription.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)
|
||||||
@@ -8435,7 +8435,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
description: Redacted subscription URL.
|
description: >-
|
||||||
|
Redacted subscription URL. May be a local/loopback address
|
||||||
|
(e.g. `http://127.0.0.1:8080/list`) — local-first fetch targets
|
||||||
|
are allowed by default (`OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS`);
|
||||||
|
cloud-metadata / link-local endpoints (169.254.0.0/16) are always
|
||||||
|
blocked.
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
mode:
|
mode:
|
||||||
|
|||||||
@@ -3,30 +3,63 @@
|
|||||||
*
|
*
|
||||||
* The subscription URL is fetched server-side (see `subscriptionService
|
* The subscription URL is fetched server-side (see `subscriptionService
|
||||||
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
|
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
|
||||||
* subscription link — could point OmniRoute at internal services or cloud
|
* subscription link — could point OmniRoute at cloud metadata (SSRF). Only
|
||||||
* metadata (SSRF). Only http/https to non-internal hosts are allowed:
|
* http/https to non-metadata hosts are allowed.
|
||||||
* loopback / private / link-local (incl. 169.254.0.0/16 cloud metadata) /
|
*
|
||||||
* unspecified addresses are blocked.
|
* 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
|
* 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
|
* helpers here) so a hostname that resolves to an internal address is still
|
||||||
* refused. Splitting the logic into pure functions keeps it unit-testable
|
* 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. */
|
/** Only these URL schemes may be used to *fetch* a subscription. */
|
||||||
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
|
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
|
||||||
|
|
||||||
// Blocked IPv4 ranges (base, mask) as 32-bit ints.
|
// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud
|
||||||
const BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
// credential pivot; never a legitimate subscription source.
|
||||||
|
const ALWAYS_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
||||||
[0x00000000, 0xff000000], // 0.0.0.0/8 unspecified
|
[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
|
[0x7f000000, 0xff000000], // 127.0.0.0/8 loopback
|
||||||
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
|
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
|
||||||
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
|
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
|
||||||
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 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})$/;
|
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||||
|
|
||||||
export function isIpv4Literal(host: string): boolean {
|
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;
|
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);
|
const n = ipv4ToLong(ip);
|
||||||
if (n === null) return false;
|
if (n === null) return false;
|
||||||
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
|
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
|
||||||
// comparing so masked results with the high bit set aren't negative.
|
// 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. */
|
// IPv4-mapped IPv6, dotted-quad tail: "::ffff:a.b.c.d" or its fully-expanded
|
||||||
export function isIpv6Blocked(ip: string): boolean {
|
// "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();
|
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 === "::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
|
if (h.startsWith("fc") || h.startsWith("fd")) return true; // unique local
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -65,8 +153,12 @@ export function isIpv6Blocked(ip: string): boolean {
|
|||||||
/** Whether `host` is an IP literal (v4 or v6). Hostnames return false. */
|
/** Whether `host` is an IP literal (v4 or v6). Hostnames return false. */
|
||||||
export function isIpLiteral(host: string): boolean {
|
export function isIpLiteral(host: string): boolean {
|
||||||
if (isIpv4Literal(host)) return true;
|
if (isIpv4Literal(host)) return true;
|
||||||
// IPv6 literals contain ":" and consist only of hex digits + ":".
|
if (!host.includes(":")) return false;
|
||||||
return host.includes(":") && /^([0-9a-fA-F:]+)$/.test(host);
|
// 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).
|
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
|
||||||
*/
|
*/
|
||||||
export function isAnyResolvedAddressBlocked(
|
export function isAnyResolvedAddressBlocked(
|
||||||
addrs: ReadonlyArray<{ address: string; family?: number }>
|
addrs: ReadonlyArray<{ address: string; family?: number }>,
|
||||||
|
opts: FetchGuardOptions = {}
|
||||||
): boolean {
|
): boolean {
|
||||||
return addrs.some(({ address, family }) => {
|
return addrs.some(({ address, family }) => {
|
||||||
const fam = family === 6 ? 6 : 4;
|
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
|
* 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
|
* host is an IP literal, it is not in a blocked range for the given
|
||||||
* structural check — they are resolved and re-checked at fetch time.
|
* `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;
|
let u: URL;
|
||||||
try {
|
try {
|
||||||
u = new URL(url);
|
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;
|
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
||||||
if (host === "") return false;
|
if (host === "") return false;
|
||||||
if (isIpLiteral(host)) {
|
if (isIpLiteral(host)) {
|
||||||
if (isIpv4Literal(host)) return !isIpv4Blocked(host);
|
if (isIpv4Literal(host)) return !isIpv4Blocked(host, opts);
|
||||||
return !isIpv6Blocked(host);
|
return !isIpv6Blocked(host, opts);
|
||||||
}
|
}
|
||||||
return true; // hostname: resolved + checked at fetch time
|
return true; // hostname: resolved + checked at fetch time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ import {
|
|||||||
isSubscriptionFetchUrlAllowed,
|
isSubscriptionFetchUrlAllowed,
|
||||||
isIpLiteral,
|
isIpLiteral,
|
||||||
isAnyResolvedAddressBlocked,
|
isAnyResolvedAddressBlocked,
|
||||||
|
type FetchGuardOptions,
|
||||||
} from "./fetchGuard";
|
} from "./fetchGuard";
|
||||||
|
import { areLocalProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
|
||||||
import { withRetry } from "./fetchRetry";
|
import { withRetry } from "./fetchRetry";
|
||||||
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
|
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
|
||||||
|
|
||||||
@@ -280,13 +282,20 @@ export async function deleteSubscription(id: string): Promise<boolean> {
|
|||||||
// ───────────────────────────── Sync + apply ─────────────────────────────
|
// ───────────────────────────── 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
|
* host. IP literals are checked structurally; hostnames are resolved and the
|
||||||
* resolved addresses are re-checked (fail closed on resolution errors). This
|
* resolved addresses are re-checked (fail closed on resolution errors).
|
||||||
* blocks SSRF to internal services / cloud metadata (169.254.169.254).
|
*
|
||||||
|
* 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> {
|
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)");
|
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
|
||||||
}
|
}
|
||||||
const host = new URL(url).hostname.toLowerCase();
|
const host = new URL(url).hostname.toLowerCase();
|
||||||
@@ -299,7 +308,7 @@ async function assertSafeFetchTarget(url: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const dns = await import("node:dns");
|
const dns = await import("node:dns");
|
||||||
const addrs = await dns.promises.lookup(bare, { all: true });
|
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");
|
throw new Error("Subscription host resolves to a blocked (internal) address");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -23,34 +23,68 @@ test("non-http(s) schemes are rejected", () => {
|
|||||||
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
|
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocked IPv4 literals are rejected", () => {
|
test("local-first (#10158): loopback/private IPv4 literals are ALLOWED by default", () => {
|
||||||
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
|
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1"]) {
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), true, ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cloud-metadata / link-local / unspecified IPv4 literals are ALWAYS blocked", () => {
|
||||||
|
for (const ip of ["169.254.169.254", "169.254.1.1", "0.0.0.0"]) {
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), false, ip);
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), false, ip);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("strict mode (allowLocal: false) rejects loopback/private IPv4 literals", () => {
|
||||||
|
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed(`https://${ip}/x`, { allowLocal: false }),
|
||||||
|
false,
|
||||||
|
ip
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("public IPv4 literals are allowed", () => {
|
test("public IPv4 literals are allowed", () => {
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed("https://8.8.8.8/x"), true);
|
assert.equal(isSubscriptionFetchUrlAllowed("https://8.8.8.8/x"), true);
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
|
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocked IPv6 literals are rejected (bracketed)", () => {
|
test("local-first (#10158): loopback/ULA IPv6 literals are ALLOWED by default", () => {
|
||||||
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
|
for (const ip of ["::1", "fc00::1", "fd12:3456::1"]) {
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), true, ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unspecified / link-local IPv6 literals are ALWAYS blocked", () => {
|
||||||
|
for (const ip of ["::", "fe80::1"]) {
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), false, ip);
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), false, ip);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("strict mode (allowLocal: false) rejects loopback/ULA IPv6 literals", () => {
|
||||||
|
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`, { allowLocal: false }),
|
||||||
|
false,
|
||||||
|
ip
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("malformed / empty-host URLs are rejected", () => {
|
test("malformed / empty-host URLs are rejected", () => {
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed("not a url"), false);
|
assert.equal(isSubscriptionFetchUrlAllowed("not a url"), false);
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed(""), false);
|
assert.equal(isSubscriptionFetchUrlAllowed(""), false);
|
||||||
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
|
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ip-range + literal helpers", () => {
|
test("ip-range + literal helpers (local-first defaults)", () => {
|
||||||
assert.equal(isIpv4Blocked("127.0.0.1"), true);
|
assert.equal(isIpv4Blocked("127.0.0.1"), false); // allowed by default (local-first)
|
||||||
assert.equal(isIpv4Blocked("169.254.169.254"), true);
|
assert.equal(isIpv4Blocked("127.0.0.1", { allowLocal: false }), true);
|
||||||
|
assert.equal(isIpv4Blocked("169.254.169.254"), true); // always blocked
|
||||||
assert.equal(isIpv4Blocked("8.8.8.8"), false);
|
assert.equal(isIpv4Blocked("8.8.8.8"), false);
|
||||||
assert.equal(isIpv6Blocked("::1"), true);
|
assert.equal(isIpv6Blocked("::1"), false); // allowed by default (local-first)
|
||||||
|
assert.equal(isIpv6Blocked("::1", { allowLocal: false }), true);
|
||||||
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
|
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
|
||||||
assert.equal(isIpLiteral("127.0.0.1"), true);
|
assert.equal(isIpLiteral("127.0.0.1"), true);
|
||||||
assert.equal(isIpLiteral("::1"), true);
|
assert.equal(isIpLiteral("::1"), true);
|
||||||
@@ -58,14 +92,22 @@ test("ip-range + literal helpers", () => {
|
|||||||
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
|
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multi-record DNS: blocks if ANY resolved address is internal", () => {
|
test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (local-first default)", () => {
|
||||||
// Hostname resolves to a public AND a private address — must be refused
|
// A private address alongside a public one is now ALLOWED by default
|
||||||
// (closes the first-address-only bypass).
|
// (local-first) — only cloud-metadata/link-local addresses stay blocked.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isAnyResolvedAddressBlocked([
|
isAnyResolvedAddressBlocked([
|
||||||
{ address: "8.8.8.8", family: 4 },
|
{ address: "8.8.8.8", family: 4 },
|
||||||
{ address: "192.168.1.10", family: 4 },
|
{ address: "192.168.1.10", family: 4 },
|
||||||
]),
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
// A cloud-metadata address among public records → still blocked.
|
||||||
|
assert.equal(
|
||||||
|
isAnyResolvedAddressBlocked([
|
||||||
|
{ address: "8.8.8.8", family: 4 },
|
||||||
|
{ address: "169.254.169.254", family: 4 },
|
||||||
|
]),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
// All public → allowed.
|
// All public → allowed.
|
||||||
@@ -76,14 +118,119 @@ test("multi-record DNS: blocks if ANY resolved address is internal", () => {
|
|||||||
]),
|
]),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
// A single internal IPv6 among public records → blocked.
|
// Strict mode (allowLocal: false): a private address is blocked again.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isAnyResolvedAddressBlocked([
|
isAnyResolvedAddressBlocked(
|
||||||
{ address: "2606:4700::1111", family: 6 },
|
[
|
||||||
{ address: "fd00::1", family: 6 },
|
{ address: "8.8.8.8", family: 4 },
|
||||||
]),
|
{ address: "192.168.1.10", family: 4 },
|
||||||
|
],
|
||||||
|
{ allowLocal: false }
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
// A single internal IPv6 among public records → blocked in strict mode.
|
||||||
|
assert.equal(
|
||||||
|
isAnyResolvedAddressBlocked(
|
||||||
|
[
|
||||||
|
{ address: "2606:4700::1111", family: 6 },
|
||||||
|
{ address: "fd00::1", family: 6 },
|
||||||
|
],
|
||||||
|
{ allowLocal: false }
|
||||||
|
),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
// Empty result set → nothing blocked.
|
// Empty result set → nothing blocked.
|
||||||
assert.equal(isAnyResolvedAddressBlocked([]), false);
|
assert.equal(isAnyResolvedAddressBlocked([]), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
|
||||||
|
// Promoted from the TDD probe (was RED on release/v3.8.50: local http subscription
|
||||||
|
// URLs were rejected by the strict SSRF guard even though the same feature already
|
||||||
|
// permits loopback for the routing half — coreEndpoint.ts's ALLOWED_LOCAL_CORE_HOSTS).
|
||||||
|
|
||||||
|
test("#10158: local (127.0.0.1) http subscription URL is allowed by default", () => {
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed("http://127.0.0.1:8080/list"),
|
||||||
|
true,
|
||||||
|
"an operator should be able to fetch a proxy list from a local HTTP server"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#10158: IMDS / cloud-metadata pivot stays blocked even with local-first default", () => {
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed("http://169.254.169.254/latest/meta-data/"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────── Regression: #10416 incomplete SSRF guard ───────────────────
|
||||||
|
// The #10158 fix left two gaps in the IPv6 side of the guard: (1) IPv4-mapped
|
||||||
|
// IPv6 literals (`::ffff:a.b.c.d`) were never unwrapped, so a mapped IMDS/
|
||||||
|
// loopback/private address skipped IPv4 range checking entirely; (2) the
|
||||||
|
// link-local check was a narrow `.startsWith("fe80")` string test instead of
|
||||||
|
// the full `fe80::/10` range (`fe80::` .. `febf:ffff::…`), so e.g. `fe90::1`
|
||||||
|
// or `febf:ffff::1` were WRONGLY ALLOWED even though they are link-local.
|
||||||
|
|
||||||
|
test("#10416: IPv4-mapped IPv6 IMDS literal stays blocked unconditionally", () => {
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed("http://[::ffff:169.254.169.254]/latest/meta-data/"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed("http://[::ffff:169.254.169.254]/latest/meta-data/", {
|
||||||
|
allowLocal: false,
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(isIpv6Blocked("::ffff:169.254.169.254"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#10416: IPv4-mapped IPv6 loopback/private literals follow IPv4 semantics", () => {
|
||||||
|
for (const mapped of ["::ffff:127.0.0.1", "::ffff:10.0.0.1", "::ffff:192.168.1.1"]) {
|
||||||
|
// local-first default: allowed, same as the bare IPv4 form.
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed(`http://[${mapped}]/x`), true, mapped);
|
||||||
|
assert.equal(isIpv6Blocked(mapped), false, mapped);
|
||||||
|
// strict mode: blocked, same as the bare IPv4 form.
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed(`http://[${mapped}]/x`, { allowLocal: false }),
|
||||||
|
false,
|
||||||
|
mapped
|
||||||
|
);
|
||||||
|
assert.equal(isIpv6Blocked(mapped, { allowLocal: false }), true, mapped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#10416: full fe80::/10 link-local range is blocked, not just the fe80 prefix", () => {
|
||||||
|
// fe80::/10 spans fe80:: through febf:ffff:…, i.e. the top 10 bits of the
|
||||||
|
// first hex group are 11111110 10xxxxxx (0xfe80-0xfebf). A narrow
|
||||||
|
// `.startsWith("fe80")` check misses fe90/fea0/febf entirely.
|
||||||
|
for (const ip of ["fe80::1", "fe90::1", "fea0::1", "febf:ffff::1"]) {
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed(`http://[${ip}]/x`), false, ip);
|
||||||
|
assert.equal(
|
||||||
|
isSubscriptionFetchUrlAllowed(`http://[${ip}]/x`, { allowLocal: false }),
|
||||||
|
false,
|
||||||
|
ip
|
||||||
|
);
|
||||||
|
assert.equal(isIpv6Blocked(ip), true, ip);
|
||||||
|
}
|
||||||
|
// fec0:: is OUTSIDE fe80::/10 (it was the deprecated IPv6 site-local
|
||||||
|
// prefix, not link-local) — must NOT be misclassified as link-local.
|
||||||
|
assert.equal(isIpv6Blocked("fec0::1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#10416: IPv4-mapped IPv6 literal host is recognized by isIpLiteral", () => {
|
||||||
|
assert.equal(isIpLiteral("::ffff:169.254.169.254"), true);
|
||||||
|
assert.equal(isIpLiteral("::ffff:127.0.0.1"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The WHATWG `URL` parser normalizes a dotted-quad IPv4-mapped IPv6 literal
|
||||||
|
// into hex-group form (`::ffff:169.254.169.254` -> `::ffff:a9fe:a9fe`), so
|
||||||
|
// `isSubscriptionFetchUrlAllowed` (which parses via `new URL()`) only ever
|
||||||
|
// sees the hex-group form for a URL-supplied host — verify that form too.
|
||||||
|
test("#10416: URL-normalized (hex-group) IPv4-mapped IPv6 literals are handled", () => {
|
||||||
|
assert.equal(new URL("http://[::ffff:169.254.169.254]/x").hostname, "[::ffff:a9fe:a9fe]");
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed("http://[::ffff:169.254.169.254]/x"), false);
|
||||||
|
assert.equal(isIpv6Blocked("::ffff:a9fe:a9fe"), true); // mapped IMDS
|
||||||
|
|
||||||
|
assert.equal(isSubscriptionFetchUrlAllowed("http://[::ffff:127.0.0.1]/x"), true);
|
||||||
|
assert.equal(isIpv6Blocked("::ffff:7f00:1"), false); // mapped loopback, local-first default
|
||||||
|
assert.equal(isIpv6Blocked("::ffff:7f00:1", { allowLocal: false }), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
@@ -229,3 +230,57 @@ test("global→rule switch re-evaluates binding: drops global, binds the selecte
|
|||||||
assert.ok(afterRule, "rule mode should bind the node to provider provA");
|
assert.ok(afterRule, "rule mode should bind the node to provider provA");
|
||||||
assert.equal(afterRule?.proxy.host, "10.0.0.5");
|
assert.equal(afterRule?.proxy.host, "10.0.0.5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
|
||||||
|
// Promoted from the TDD probe (was RED on release/v3.8.50: createSubscription against a
|
||||||
|
// real local (127.0.0.1) HTTP server failed with "Fetch failed: Subscription URL is not
|
||||||
|
// allowed (scheme or host blocked)" even though coreEndpoint.ts already permits routing
|
||||||
|
// through a loopback core). Uses a REAL local HTTP server (not a fetch stub) so the fix
|
||||||
|
// is proven end-to-end through assertSafeFetchTarget's SSRF guard.
|
||||||
|
|
||||||
|
function startLocalSubscriptionServer(
|
||||||
|
body: string
|
||||||
|
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const srv = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("no addr");
|
||||||
|
resolve({
|
||||||
|
url: `http://127.0.0.1:${addr.port}/list`,
|
||||||
|
close: () => new Promise((r) => srv.close(() => r())),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("#10158: createSubscription against a real local (127.0.0.1) HTTP server syncs ok", async () => {
|
||||||
|
await reset();
|
||||||
|
const LIST_BODY = [
|
||||||
|
"proxies:",
|
||||||
|
" - name: local-node",
|
||||||
|
" type: http",
|
||||||
|
" server: 127.0.0.1",
|
||||||
|
" port: 8080",
|
||||||
|
].join("\n");
|
||||||
|
const { url, close } = await startLocalSubscriptionServer(LIST_BODY);
|
||||||
|
try {
|
||||||
|
const created = await sub.createSubscription({
|
||||||
|
name: "local-list",
|
||||||
|
url,
|
||||||
|
enabled: true,
|
||||||
|
mode: "global",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
created.status,
|
||||||
|
"ok",
|
||||||
|
`expected ok, got status=${created.status} error=${created.error}`
|
||||||
|
);
|
||||||
|
assert.ok((created.lastNodes ?? []).length >= 1, "expected at least one parsed node");
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user