From 4caf122129102fe0f66004ded8dc327ec1c4a915 Mon Sep 17 00:00:00 2001 From: "Claude Code (SERV0 agent)" Date: Fri, 11 Sep 2026 02:18:23 +0200 Subject: [PATCH] fix(security): add SSRF guard to Fabric/OneLake adapter's sqlEndpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connection_config.sqlEndpoint on POST /api/0pt/sources/{sourceId}/query was passed straight from the request body to the mssql TDS driver as the outbound connection target, with only its http(s):// scheme stripped — no hostname allowlist or private/loopback/link-local/cloud-metadata guard. This route already requires a Zitadel bearer token + tenant match (P014 tenant guard), so this was not anonymously exploitable, but it is the exact SSRF shape found and exploited-in-principle elsewhere in this estate tonight (press0), so it's worth closing as defense-in-depth. Adds src/lib/security/ssrf-guard.ts: resolves the endpoint's hostname and rejects any address in a private/loopback/link-local/cloud-metadata/ unspecified range (RFC1918, 127.0.0.0/8, 169.254.0.0/16 incl. the 169.254.169.254 metadata address, ::1, fc00::/7, fe80::/10, IPv4-mapped IPv6), failing closed on DNS resolution errors. The range-check logic is ported 1:1 from press0's src/lib/security/ssrf-guard.ts built the same night for the same bug class against a fetch()-based URL there; this module's surface differs because sqlEndpoint is a bare host[:port], not a fetchable URL. The DNS lookup is injectable (mirrors this adapter's existing fetchImpl pattern) so tests are hermetic. FabricOneLakeAdapter.getPool() now calls the guard before loadDriver()/ getToken()/opening the TDS pool, so a blocked target never reaches a real outbound connection attempt or spends an AAD token exchange. Tests: new tests/p0/fabric-onelake-ssrf-guard.test.ts unit-tests the guard module directly (range boundaries, IPv4-mapped-IPv6 bypass attempt, fail-closed on DNS failure/empty answer). New adapter-level tests in tests/p0/fabric-onelake-adapter.test.ts prove (a) a private-IP-resolving sqlEndpoint is rejected before fetch/connect is ever called, (b) a literal loopback sqlEndpoint is rejected the same way, and (c) a normal external Fabric hostname still passes the guard and completes the query — the guard doesn't false-block legitimate targets. Existing adapter tests are updated to inject a deterministic DNS resolver so they stay hermetic instead of depending on real DNS. Full suite: 997 passed, 0 failed, 184 skipped (self-skipping realinfra tests with no live backend this run). typecheck and lint clean on all touched files. Co-Authored-By: Claude Sonnet 5 --- src/adapters/fabric-onelake-adapter.ts | 35 +++- src/lib/security/ssrf-guard.ts | 190 +++++++++++++++++++++ tests/p0/fabric-onelake-adapter.test.ts | 119 ++++++++++++- tests/p0/fabric-onelake-ssrf-guard.test.ts | 158 +++++++++++++++++ 4 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 src/lib/security/ssrf-guard.ts create mode 100644 tests/p0/fabric-onelake-ssrf-guard.test.ts diff --git a/src/adapters/fabric-onelake-adapter.ts b/src/adapters/fabric-onelake-adapter.ts index af7c13f..bf57d9c 100644 --- a/src/adapters/fabric-onelake-adapter.ts +++ b/src/adapters/fabric-onelake-adapter.ts @@ -35,8 +35,21 @@ * Once `mssql` is added to package.json (`pnpm add mssql`), query() works * against a real endpoint with no code change here. Until then, `query()` * throws a clear, actionable error — see docs/P0_QUERY_SOURCE_FLOW.md. + * + * SSRF guard: `connection_config.sqlEndpoint` is caller-supplied per-request + * (via POST /api/0pt/sources/{sourceId}/query -> source-query-service.ts) + * and, until now, only had its scheme stripped before being handed to the + * `mssql` driver as the outbound connection target — no hostname allowlist + * or private/loopback/link-local/cloud-metadata check. `getPool()` now + * resolves and validates the endpoint via + * `src/lib/security/ssrf-guard.ts::assertConnectionHostIsSafe` *before* + * constructing the pool, closing that gap defense-in-depth (this route + * already requires a Zitadel bearer token + tenant match — see + * src/http/tenant-guard.ts). */ +import { assertConnectionHostIsSafe, type DnsLookupFn } from "../lib/security/ssrf-guard"; + const DEFAULT_SQL_SCOPE = "https://database.windows.net/.default"; const TOKEN_REFRESH_SKEW_MS = 60_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -138,6 +151,13 @@ export class FabricOneLakeAdapter { constructor( private readonly config: FabricOneLakeConnectionConfig, private readonly fetchImpl: typeof fetch = fetch, + /** + * Injectable DNS resolver for the SSRF guard (see `getPool()`). + * Overridable for tests, same rationale as `fetchImpl`; defaults to the + * real `node:dns` resolver in production (undefined -> ssrf-guard's own + * default). + */ + private readonly dnsLookupImpl?: DnsLookupFn, ) {} private async getToken(): Promise { @@ -171,6 +191,13 @@ export class FabricOneLakeAdapter { private async getPool(): Promise> { if (this.pool) return this.pool; + // SSRF guard: resolve + validate the caller-supplied sqlEndpoint before + // doing anything else — in particular before loadDriver()/getToken(), so + // a blocked target never gets as far as a real outbound connection + // attempt (or spends an AAD token exchange on a request that will be + // refused anyway). Throws SsrfBlockedHostError for a private/loopback/ + // link-local/cloud-metadata/unspecified address, or an unresolvable host. + await assertConnectionHostIsSafe(this.config.sqlEndpoint, this.dnsLookupImpl); const sql = await this.loadDriver(); const token = await this.getToken(); const poolConfig = { @@ -218,6 +245,10 @@ export class FabricOneLakeAdapter { } } -export function createFabricOneLakeAdapter(config: FabricOneLakeConnectionConfig, fetchImpl?: typeof fetch): FabricOneLakeAdapter { - return new FabricOneLakeAdapter(config, fetchImpl); +export function createFabricOneLakeAdapter( + config: FabricOneLakeConnectionConfig, + fetchImpl?: typeof fetch, + dnsLookupImpl?: DnsLookupFn, +): FabricOneLakeAdapter { + return new FabricOneLakeAdapter(config, fetchImpl, dnsLookupImpl); } diff --git a/src/lib/security/ssrf-guard.ts b/src/lib/security/ssrf-guard.ts new file mode 100644 index 0000000..a15b8a1 --- /dev/null +++ b/src/lib/security/ssrf-guard.ts @@ -0,0 +1,190 @@ +/** + * src/lib/security/ssrf-guard.ts + * ================================= + * SSRF guard for outbound connections this backend opens to a caller- + * supplied host. Today's one caller: `FabricOneLakeAdapter.getPool()` + * (src/adapters/fabric-onelake-adapter.ts), whose `connection_config. + * sqlEndpoint` arrives via `POST /api/0pt/sources/{sourceId}/query -> + * source-query-service.ts` — a caller-supplied value that, before this + * guard, only had its `http(s)://` scheme stripped (see `stripScheme` in + * fabric-onelake-adapter.ts) before being handed straight to the `mssql` + * TDS driver as the connection target. Nothing stopped a request from + * pointing `sqlEndpoint` at 127.0.0.1, an RFC1918 address, or the + * cloud-metadata link-local address (169.254.169.254) and having this + * backend open a real outbound SQL connection there. That route requires a + * valid Zitadel bearer token + tenant match (the P014 tenant guard, + * src/http/tenant-guard.ts) today, so this is defense-in-depth rather than + * the only control — worth closing anyway given it is the exact SSRF shape + * found elsewhere in this estate (press0's fulltext-ingestion fetch path). + * + * The range-check logic (BLOCKED_V4_RANGES / isBlockedV4 / isBlockedV6) is + * ported 1:1 from press0's `src/lib/security/ssrf-guard.ts`, built the same + * night for the same class of bug against a `fetch()`-based URL there. This + * module's public surface differs because the input here is a bare + * `host[:port]` connection endpoint, not a fetchable URL — no scheme, path + * or query is meaningful on the wire — so `extractConnectionHostname` + * parses that shape instead of `new URL(...)`. The safety decision itself + * is identical: resolve the hostname, reject any resolved address in a + * private/loopback/link-local/cloud-metadata/unspecified range, and fail + * closed (treat as unsafe) on resolution failure. Checking the *resolved* + * address rather than string-matching the hostname is what stops a + * public-looking hostname whose DNS record actually points at an internal + * address — plain hostname allowlisting doesn't catch that. + * + * The DNS lookup is injectable (`lookupImpl`) — the same dependency- + * injection pattern this file's caller already uses for `fetchImpl` — so + * tests get a deterministic resolver instead of depending on real DNS. + */ + +import net from "node:net"; +import { promises as dns } from "node:dns"; + +export interface DnsLookupAddress { + address: string; + family: number; +} + +/** Matches the subset of `dns.promises.lookup(host, { all: true, verbatim: true })` this module needs. */ +export type DnsLookupFn = (hostname: string) => Promise; + +const defaultLookup: DnsLookupFn = (hostname) => dns.lookup(hostname, { all: true, verbatim: true }); + +const BLOCKED_V4_RANGES: ReadonlyArray = [ + ["10.0.0.0", 8], // RFC1918 private + ["172.16.0.0", 12], // RFC1918 private + ["192.168.0.0", 16], // RFC1918 private + ["127.0.0.0", 8], // loopback + ["169.254.0.0", 16], // link-local (also cloud metadata: 169.254.169.254) + ["0.0.0.0", 8], // "this network" / unspecified +]; + +function ipv4ToInt(ip: string): number { + return ( + ip + .split(".") + .reduce((acc, octet) => (acc << 8) + (Number(octet) & 0xff), 0) >>> 0 + ); +} + +export function isBlockedV4(ip: string): boolean { + if (!net.isIPv4(ip)) return false; + const ipInt = ipv4ToInt(ip); + return BLOCKED_V4_RANGES.some(([base, bits]) => { + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ipInt & mask) === (ipv4ToInt(base) & mask); + }); +} + +function firstHextet(ip: string): number | null { + const first = ip.split(":")[0]; + if (first === undefined) return null; + if (first === "") return 0; // address starts with "::" + const n = parseInt(first, 16); + return Number.isNaN(n) ? null : n; +} + +export function isBlockedV6(ip: string): boolean { + if (!net.isIPv6(ip)) return false; + const lower = ip.toLowerCase(); + if (lower === "::1" || lower === "::") return true; // loopback / unspecified + + // IPv4-mapped IPv6 (::ffff:a.b.c.d) — unwrap and re-check as v4, since this + // is the standard way to smuggle a blocked v4 address past a v6-literal- + // shaped filter. + if (lower.startsWith("::ffff:")) { + const v4 = lower.slice("::ffff:".length); + if (net.isIPv4(v4)) return isBlockedV4(v4); + } + + const h = firstHextet(lower); + if (h === null) return false; + if (h >= 0xfc00 && h <= 0xfdff) return true; // fc00::/7 — unique local + if (h >= 0xfe80 && h <= 0xfebf) return true; // fe80::/10 — link-local + return false; +} + +function isBlockedAddress(address: string, family: number): boolean { + if (family === 4) return isBlockedV4(address); + if (family === 6) return isBlockedV6(address); + return true; // unknown family — fail closed +} + +export type HostSafetyResult = { ok: true } | { ok: false; reason: string }; + +/** + * Parses the bare connection-endpoint shape `FabricOneLakeConnectionConfig + * .sqlEndpoint` takes: an optional `scheme://` prefix (already tolerated + * elsewhere via `stripScheme`), a hostname or bracketed IPv6 literal, an + * optional `:port`, and — defensively, even though a SQL endpoint should + * never carry one — a path/query/fragment, which is discarded. Returns the + * bare hostname, ready to resolve. + */ +export function extractConnectionHostname(rawEndpoint: string): string { + let value = rawEndpoint.trim().replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ""); + value = value.split(/[/?#]/)[0] ?? ""; + + // Bracketed IPv6 literal, optionally with a port: "[::1]" or "[::1]:1433". + const ipv6Match = value.match(/^\[([^\]]+)\](?::\d+)?$/); + if (ipv6Match) return ipv6Match[1]; + + // A bare (unbracketed) IPv6 literal has no unambiguous port form — RFC + // 3986 requires brackets for that precisely because the address itself is + // full of colons. If the whole remaining value already parses as a valid + // IPv6 address, return it as-is instead of trying to strip a "port" out + // of one of its own colons (naive last-colon-splitting would truncate + // "fe80::1" to "fe80:"). + if (net.isIP(value) === 6) return value; + + const portMatch = value.match(/^(.+):(\d+)$/); + if (portMatch) return portMatch[1]; + + return value; +} + +/** + * Resolves `hostname` and checks every returned address against the blocked + * ranges. Fails closed: DNS resolution errors or an empty answer are + * treated as unsafe, same as press0's `isUrlSafeToFetch`. + */ +export async function checkHostnameSafety( + hostname: string, + lookupImpl: DnsLookupFn = defaultLookup, +): Promise { + if (!hostname) return { ok: false, reason: "empty_hostname" }; + + let addresses: DnsLookupAddress[]; + try { + addresses = await lookupImpl(hostname); + } catch { + return { ok: false, reason: "dns_resolution_failed" }; + } + + if (addresses.length === 0) { + return { ok: false, reason: "dns_resolution_failed" }; + } + + if (addresses.some((a) => isBlockedAddress(a.address, a.family))) { + return { ok: false, reason: "blocked_address_range" }; + } + + return { ok: true }; +} + +export class SsrfBlockedHostError extends Error {} + +/** + * Throws `SsrfBlockedHostError` if `rawEndpoint`'s hostname resolves to (or + * literally is) a private/loopback/link-local/cloud-metadata/unspecified + * address. Call this before opening the outbound connection — the check + * itself never opens one. + */ +export async function assertConnectionHostIsSafe(rawEndpoint: string, lookupImpl?: DnsLookupFn): Promise { + const hostname = extractConnectionHostname(rawEndpoint); + const result = await checkHostnameSafety(hostname, lookupImpl); + if (!result.ok) { + throw new SsrfBlockedHostError( + `fabric_onelake_adapter: sqlEndpoint host "${hostname}" is blocked by the SSRF guard (${result.reason}) — ` + + `refusing to open an outbound connection to a private/loopback/link-local/cloud-metadata/unspecified address`, + ); + } +} diff --git a/tests/p0/fabric-onelake-adapter.test.ts b/tests/p0/fabric-onelake-adapter.test.ts index 8c23b22..a2a4cfa 100644 --- a/tests/p0/fabric-onelake-adapter.test.ts +++ b/tests/p0/fabric-onelake-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { acquireFabricAccessToken, type FabricOneLakeCredentials } from "@/src/adapters/fabric-onelake-adapter"; +import type { DnsLookupFn } from "@/src/lib/security/ssrf-guard"; const CREDS: FabricOneLakeCredentials = { tenantId: "tenant-a", clientId: "client-a", clientSecret: "secret-a" }; @@ -7,6 +8,23 @@ function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } +/** + * Deterministic stand-in for real DNS: every test in this file that opens a + * pool now goes through the SSRF guard added to `getPool()`, so a fixed, + * injected resolver keeps these tests hermetic (no live network dependency) + * instead of relying on the test fixtures' fake hostnames actually + * resolving. Returns a public, non-blocked address for any hostname unless + * overridden — use `blockedDnsLookup` below to simulate a hostname that + * resolves to a private/internal address. + */ +function publicDnsLookup(_hostname: string): ReturnType { + return Promise.resolve([{ address: "20.91.151.2", family: 4 }]); +} + +function blockedDnsLookup(address: string, family = 4): DnsLookupFn { + return () => Promise.resolve([{ address, family }]); +} + describe("acquireFabricAccessToken — OAuth client-credentials flow", () => { it("posts client_credentials grant to the caller's tenant AAD endpoint with the default Fabric SQL scope", async () => { let capturedUrl = ""; @@ -106,7 +124,7 @@ describe("FabricOneLakeAdapter.query — TDS execution via a mocked mssql driver vi.doMock("mssql", () => ({ default: { ConnectionPool: FakeConnectionPool } })); const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); - const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch); + const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch, publicDnsLookup); const result = await adapter.query("SELECT * FROM dbo.customers WHERE id > @minId", { minId: 1 }); expect(result.rows).toEqual([{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]); @@ -137,7 +155,7 @@ describe("FabricOneLakeAdapter.query — TDS execution via a mocked mssql driver vi.doMock("mssql", () => ({ default: { ConnectionPool: FakeConnectionPool } })); const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); - const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch); + const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch, publicDnsLookup); await adapter.query("SELECT 1"); await adapter.query("SELECT 2"); @@ -151,8 +169,103 @@ describe("FabricOneLakeAdapter.query — TDS execution via a mocked mssql driver // dependency (see fabric-onelake-adapter.ts header), so the real dynamic // import genuinely fails, exercising the adapter's real error path. const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); - const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch); + const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch, publicDnsLookup); await expect(adapter.query("SELECT 1")).rejects.toThrow(/mssql.*not installed/i); }); }); + +describe("FabricOneLakeAdapter.query — SSRF guard on connection_config.sqlEndpoint", () => { + const CONFIG = { + credentials: CREDS, + sqlEndpoint: "https://my-endpoint.datawarehouse.fabric.microsoft.com/", + database: "lh_test", + }; + + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("mssql"); + }); + + function fakePoolFactory() { + const queryMock = vi.fn(async () => ({ recordset: [{ ok: 1 }] })); + const connectMock = vi.fn(async () => {}); + const closeMock = vi.fn(async () => {}); + class FakeConnectionPool { + connect = connectMock; + request() { + return { input: vi.fn(), query: queryMock }; + } + close = closeMock; + } + return { FakeConnectionPool, queryMock, connectMock, closeMock }; + } + + it("rejects a private/internal-IP sqlEndpoint before ever opening a TDS connection", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ access_token: "tok_ssrf", expires_in: 3600 })); + const { FakeConnectionPool, connectMock } = fakePoolFactory(); + vi.doMock("mssql", () => ({ default: { ConnectionPool: FakeConnectionPool } })); + + const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); + const { SsrfBlockedHostError } = await import("@/src/lib/security/ssrf-guard"); + + // An internal-looking hostname whose "DNS record" (mocked here) resolves + // to an RFC1918 private address — the exact shape a caller-supplied + // sqlEndpoint could take if pointed inward. + const adapter = new FabricOneLakeAdapter( + { ...CONFIG, sqlEndpoint: "internal-warehouse.corp.example.com" }, + fetchMock as unknown as typeof fetch, + blockedDnsLookup("10.1.2.3"), + ); + + let caught: unknown; + try { + await adapter.query("SELECT 1"); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(SsrfBlockedHostError); + expect((caught as Error).message).toMatch(/blocked_address_range/); + // The guard must run before the outbound connection is opened at all — + // fetch (AAD token exchange) and the TDS pool's connect() should never + // have been reached. + expect(fetchMock).not.toHaveBeenCalled(); + expect(connectMock).not.toHaveBeenCalled(); + }); + + it("rejects a literal loopback-IP sqlEndpoint (e.g. an attempt to reach 127.0.0.1) the same way", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ access_token: "tok_ssrf", expires_in: 3600 })); + const { FakeConnectionPool, connectMock } = fakePoolFactory(); + vi.doMock("mssql", () => ({ default: { ConnectionPool: FakeConnectionPool } })); + + const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); + const { SsrfBlockedHostError } = await import("@/src/lib/security/ssrf-guard"); + + const adapter = new FabricOneLakeAdapter( + { ...CONFIG, sqlEndpoint: "http://127.0.0.1:1433" }, + fetchMock as unknown as typeof fetch, + // Real DNS resolution of a literal IP just returns that IP — this + // fixture doesn't even need the injected mock to prove the point, but + // stays consistent with the rest of this file's determinism. + blockedDnsLookup("127.0.0.1"), + ); + + await expect(adapter.query("SELECT 1")).rejects.toBeInstanceOf(SsrfBlockedHostError); + expect(connectMock).not.toHaveBeenCalled(); + }); + + it("does not false-block a normal external Fabric hostname — query still succeeds through the guard", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ access_token: "tok_ssrf_ok", expires_in: 3600 })); + const { FakeConnectionPool, queryMock, connectMock } = fakePoolFactory(); + vi.doMock("mssql", () => ({ default: { ConnectionPool: FakeConnectionPool } })); + + const { FabricOneLakeAdapter } = await import("@/src/adapters/fabric-onelake-adapter"); + const adapter = new FabricOneLakeAdapter(CONFIG, fetchMock as unknown as typeof fetch, publicDnsLookup); + + const result = await adapter.query("SELECT 1"); + + expect(result.rows).toEqual([{ ok: 1 }]); + expect(connectMock).toHaveBeenCalledTimes(1); + expect(queryMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/p0/fabric-onelake-ssrf-guard.test.ts b/tests/p0/fabric-onelake-ssrf-guard.test.ts new file mode 100644 index 0000000..9ee1e2f --- /dev/null +++ b/tests/p0/fabric-onelake-ssrf-guard.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest"; +import { + assertConnectionHostIsSafe, + checkHostnameSafety, + extractConnectionHostname, + isBlockedV4, + isBlockedV6, + SsrfBlockedHostError, + type DnsLookupFn, +} from "@/src/lib/security/ssrf-guard"; + +function lookupReturning(address: string, family: 4 | 6 = 4): DnsLookupFn { + return () => Promise.resolve([{ address, family }]); +} + +function lookupFailing(): DnsLookupFn { + return () => Promise.reject(new Error("ENOTFOUND")); +} + +function lookupEmpty(): DnsLookupFn { + return () => Promise.resolve([]); +} + +describe("extractConnectionHostname — parses the bare host[:port] shape sqlEndpoint takes", () => { + it("strips an http(s):// scheme", () => { + expect(extractConnectionHostname("https://ep.datawarehouse.fabric.microsoft.com")).toBe( + "ep.datawarehouse.fabric.microsoft.com", + ); + expect(extractConnectionHostname("http://10.0.0.5")).toBe("10.0.0.5"); + }); + + it("strips a trailing port", () => { + expect(extractConnectionHostname("ep.fabric.microsoft.com:1433")).toBe("ep.fabric.microsoft.com"); + expect(extractConnectionHostname("http://127.0.0.1:1433")).toBe("127.0.0.1"); + }); + + it("strips a trailing path/query/fragment defensively", () => { + expect(extractConnectionHostname("https://ep.fabric.microsoft.com/whatever?x=1#y")).toBe("ep.fabric.microsoft.com"); + }); + + it("unwraps a bracketed IPv6 literal, with or without a port", () => { + expect(extractConnectionHostname("[::1]")).toBe("::1"); + expect(extractConnectionHostname("[::1]:1433")).toBe("::1"); + expect(extractConnectionHostname("[fe80::1]:1433")).toBe("fe80::1"); + }); + + it("does not mistake a bare (unbracketed) IPv6 literal's colons for a port separator", () => { + expect(extractConnectionHostname("fe80::1")).toBe("fe80::1"); + }); + + it("trims whitespace and a trailing slash", () => { + expect(extractConnectionHostname(" https://ep.fabric.microsoft.com/ ".trim())).toBe("ep.fabric.microsoft.com"); + }); +}); + +describe("isBlockedV4 — RFC1918 / loopback / link-local / unspecified ranges", () => { + it.each([ + ["10.0.0.1", true], + ["10.255.255.255", true], + ["172.16.0.1", true], + ["172.31.255.255", true], + ["172.15.255.255", false], // just outside 172.16.0.0/12 + ["172.32.0.0", false], // just outside 172.16.0.0/12 + ["192.168.0.1", true], + ["127.0.0.1", true], + ["169.254.169.254", true], // cloud-metadata address + ["169.254.0.1", true], + ["0.0.0.0", true], + ["8.8.8.8", false], + ["20.91.151.2", false], // a real public Azure IP (used elsewhere in these fixtures) + ["1.1.1.1", false], + ])("isBlockedV4(%s) === %s", (ip, expected) => { + expect(isBlockedV4(ip)).toBe(expected); + }); + + it("returns false for a non-IPv4 string instead of throwing", () => { + expect(isBlockedV4("not-an-ip")).toBe(false); + expect(isBlockedV4("::1")).toBe(false); + }); +}); + +describe("isBlockedV6 — loopback / unique-local / link-local, plus IPv4-mapped bypass", () => { + it.each([ + ["::1", true], + ["::", true], + ["fc00::1", true], + ["fd12:3456::1", true], + ["fe80::1", true], + ["2001:4860:4860::8888", false], // public (Google DNS v6) + ])("isBlockedV6(%s) === %s", (ip, expected) => { + expect(isBlockedV6(ip)).toBe(expected); + }); + + it("unwraps an IPv4-mapped IPv6 address and re-checks it as v4 (filter-bypass form)", () => { + expect(isBlockedV6("::ffff:127.0.0.1")).toBe(true); + expect(isBlockedV6("::ffff:169.254.169.254")).toBe(true); + expect(isBlockedV6("::ffff:8.8.8.8")).toBe(false); + }); + + it("returns false for a non-IPv6 string instead of throwing", () => { + expect(isBlockedV6("10.0.0.1")).toBe(false); + expect(isBlockedV6("not-an-ip")).toBe(false); + }); +}); + +describe("checkHostnameSafety — resolves via the injected lookup and fails closed", () => { + it("blocks a hostname whose resolved address is a private IP (the DNS-rebinding case a plain string allowlist would miss)", async () => { + const result = await checkHostnameSafety("looks-public.example.com", lookupReturning("10.1.2.3")); + expect(result).toEqual({ ok: false, reason: "blocked_address_range" }); + }); + + it("blocks the cloud-metadata address specifically", async () => { + const result = await checkHostnameSafety("metadata.internal", lookupReturning("169.254.169.254")); + expect(result).toEqual({ ok: false, reason: "blocked_address_range" }); + }); + + it("allows a hostname that resolves to a normal public IP", async () => { + const result = await checkHostnameSafety("ep.datawarehouse.fabric.microsoft.com", lookupReturning("20.91.151.2")); + expect(result).toEqual({ ok: true }); + }); + + it("fails closed when DNS resolution errors", async () => { + const result = await checkHostnameSafety("does-not-resolve.invalid", lookupFailing()); + expect(result).toEqual({ ok: false, reason: "dns_resolution_failed" }); + }); + + it("fails closed when DNS resolution returns no addresses", async () => { + const result = await checkHostnameSafety("no-answers.invalid", lookupEmpty()); + expect(result).toEqual({ ok: false, reason: "dns_resolution_failed" }); + }); + + it("fails closed on an empty hostname", async () => { + const result = await checkHostnameSafety("", lookupReturning("20.91.151.2")); + expect(result).toEqual({ ok: false, reason: "empty_hostname" }); + }); +}); + +describe("assertConnectionHostIsSafe — the guard fabric-onelake-adapter.ts calls", () => { + it("throws SsrfBlockedHostError for a private-IP sqlEndpoint", async () => { + await expect(assertConnectionHostIsSafe("internal-db.corp.example.com", lookupReturning("192.168.1.50"))).rejects.toBeInstanceOf( + SsrfBlockedHostError, + ); + }); + + it("throws for a literal loopback sqlEndpoint with no DNS lookup needed at all", async () => { + // Real dns.lookup() on a literal IP just returns that IP without a + // network round-trip; simulate that here via the injected lookup. + await expect(assertConnectionHostIsSafe("http://127.0.0.1:1433", lookupReturning("127.0.0.1"))).rejects.toBeInstanceOf( + SsrfBlockedHostError, + ); + }); + + it("resolves without throwing for a normal external Fabric/OneLake hostname", async () => { + await expect( + assertConnectionHostIsSafe("https://my-tenant.datawarehouse.fabric.microsoft.com/", lookupReturning("20.91.151.2")), + ).resolves.toBeUndefined(); + }); +}); -- 2.54.0