fix(security): add SSRF guard to Fabric/OneLake adapter's sqlEndpoint #54

Merged
admin merged 1 commits from fix/fabric-onelake-ssrf-guard into main 2026-09-13 15:39:21 +00:00
4 changed files with 497 additions and 5 deletions
+33 -2
View File
@@ -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<string> {
@@ -171,6 +191,13 @@ export class FabricOneLakeAdapter {
private async getPool(): Promise<InstanceType<MssqlLike["ConnectionPool"]>> {
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);
}
+190
View File
@@ -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<DnsLookupAddress[]>;
const defaultLookup: DnsLookupFn = (hostname) => dns.lookup(hostname, { all: true, verbatim: true });
const BLOCKED_V4_RANGES: ReadonlyArray<readonly [string, number]> = [
["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<HostSafetyResult> {
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<void> {
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`,
);
}
}
+116 -3
View File
@@ -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<DnsLookupFn> {
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);
});
});
+158
View File
@@ -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();
});
});