fix(security): add SSRF guard to Fabric/OneLake adapter's sqlEndpoint #54
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user