From e7718b0423344e203e6c31358ab53eb4bb0957ab Mon Sep 17 00:00:00 2001 From: adevwithpurpose Date: Fri, 14 Aug 2026 18:39:08 -0300 Subject: [PATCH] fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fixes/10158-local-proxy-subscription.md | 1 + docs/openapi.yaml | 7 +- src/lib/proxySubscription/fetchGuard.ts | 75 +++++++++---- .../proxySubscription/subscriptionService.ts | 19 +++- .../unit/proxySubscription.fetchGuard.test.ts | 105 +++++++++++++++--- tests/unit/proxySubscription.service.test.ts | 55 +++++++++ 6 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 changelog.d/fixes/10158-local-proxy-subscription.md diff --git a/changelog.d/fixes/10158-local-proxy-subscription.md b/changelog.d/fixes/10158-local-proxy-subscription.md new file mode 100644 index 0000000000..76194c6d49 --- /dev/null +++ b/changelog.d/fixes/10158-local-proxy-subscription.md @@ -0,0 +1 @@ +- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f4cfd804ca..505b3cac6a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -8435,7 +8435,12 @@ components: type: string url: 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: type: boolean mode: diff --git a/src/lib/proxySubscription/fetchGuard.ts b/src/lib/proxySubscription/fetchGuard.ts index 5391c2f3a2..dc058c1000 100644 --- a/src/lib/proxySubscription/fetchGuard.ts +++ b/src/lib/proxySubscription/fetchGuard.ts @@ -3,30 +3,57 @@ * * 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. */ /** Only these URL schemes may be used to *fetch* a subscription. */ export const ALLOWED_FETCH_SCHEMES = new Set(["http:", "https:"]); -// Blocked IPv4 ranges (base, mask) as 32-bit ints. -const BLOCKED_IPV4: ReadonlyArray = [ +// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud +// credential pivot; never a legitimate subscription source. +const ALWAYS_BLOCKED_IPV4: ReadonlyArray = [ [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 = [ [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 +71,24 @@ 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 { +/** Blocked IPv6 addresses: unspecified/link-local always; loopback/ULA only when strict. */ +export function isIpv6Blocked(ip: string, opts: FetchGuardOptions = {}): boolean { + const allowLocal = opts.allowLocal ?? true; const h = ip.toLowerCase(); + if (h === "::") return true; // unspecified — always blocked + if (h.startsWith("fe80")) return true; // link-local — 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; } @@ -78,20 +109,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 +137,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 } diff --git a/src/lib/proxySubscription/subscriptionService.ts b/src/lib/proxySubscription/subscriptionService.ts index 94b74d0d3d..60d0ce7158 100644 --- a/src/lib/proxySubscription/subscriptionService.ts +++ b/src/lib/proxySubscription/subscriptionService.ts @@ -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 { // ───────────────────────────── 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 { - 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 { 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) { diff --git a/tests/unit/proxySubscription.fetchGuard.test.ts b/tests/unit/proxySubscription.fetchGuard.test.ts index 51db57cf09..091d3c9750 100644 --- a/tests/unit/proxySubscription.fetchGuard.test.ts +++ b/tests/unit/proxySubscription.fetchGuard.test.ts @@ -23,34 +23,68 @@ test("non-http(s) schemes are rejected", () => { assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false); }); -test("blocked IPv4 literals are rejected", () => { - 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"]) { +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"]) { + 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); } }); +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", () => { assert.equal(isSubscriptionFetchUrlAllowed("https://8.8.8.8/x"), true); assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true); }); -test("blocked IPv6 literals are rejected (bracketed)", () => { - for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) { +test("local-first (#10158): loopback/ULA IPv6 literals are ALLOWED by default", () => { + 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); } }); +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", () => { assert.equal(isSubscriptionFetchUrlAllowed("not a url"), false); assert.equal(isSubscriptionFetchUrlAllowed(""), false); assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host }); -test("ip-range + literal helpers", () => { - assert.equal(isIpv4Blocked("127.0.0.1"), true); - assert.equal(isIpv4Blocked("169.254.169.254"), true); +test("ip-range + literal helpers (local-first defaults)", () => { + assert.equal(isIpv4Blocked("127.0.0.1"), false); // allowed by default (local-first) + 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(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(isIpLiteral("127.0.0.1"), true); assert.equal(isIpLiteral("::1"), true); @@ -58,14 +92,22 @@ test("ip-range + literal helpers", () => { assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]); }); -test("multi-record DNS: blocks if ANY resolved address is internal", () => { - // Hostname resolves to a public AND a private address — must be refused - // (closes the first-address-only bypass). +test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (local-first default)", () => { + // A private address alongside a public one is now ALLOWED by default + // (local-first) — only cloud-metadata/link-local addresses stay blocked. assert.equal( isAnyResolvedAddressBlocked([ { address: "8.8.8.8", 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 ); // All public → allowed. @@ -76,14 +118,45 @@ test("multi-record DNS: blocks if ANY resolved address is internal", () => { ]), false ); - // A single internal IPv6 among public records → blocked. + // Strict mode (allowLocal: false): a private address is blocked again. assert.equal( - isAnyResolvedAddressBlocked([ - { address: "2606:4700::1111", family: 6 }, - { address: "fd00::1", family: 6 }, - ]), + isAnyResolvedAddressBlocked( + [ + { 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 ); // Empty result set → nothing blocked. 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); +}); diff --git a/tests/unit/proxySubscription.service.test.ts b/tests/unit/proxySubscription.service.test.ts index a66332ef8a..442788eb79 100644 --- a/tests/unit/proxySubscription.service.test.ts +++ b/tests/unit/proxySubscription.service.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import http from "node:http"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-")); 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.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 }> { + 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(); + } +});