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
Owner

What

connection_config.sqlEndpoint on POST /api/0pt/sources/{sourceId}/query (source-query-service.ts -> fabric-onelake-adapter.ts) was passed straight from the request body to the mssql TDS driver as the outbound connection target, with only its http(s):// scheme stripped (stripScheme()). No hostname allowlist or private-IP/internal-network guard existed, so a caller-supplied sqlEndpoint could point this backend's outbound SQL connection at 127.0.0.1, an RFC1918 address, or the cloud-metadata link-local address (169.254.169.254).

This route already requires a valid Zitadel bearer token + tenant match (the P014 tenant guard, src/http/tenant-guard.ts), so it was not anonymously exploitable today — this is defense-in-depth, closing the exact SSRF shape found and exploited-in-principle elsewhere in this estate tonight (press0's fulltext-ingestion fetch path).

Change

  • src/lib/security/ssrf-guard.ts (new): resolves the endpoint's hostname and rejects any resolved address in a private/loopback/link-local/cloud-metadata/unspecified range (RFC1918, 127.0.0.0/8, 169.254.0.0/16 incl. 169.254.169.254, ::1, fc00::/7, fe80::/10, and the IPv4-mapped-IPv6 bypass form ::ffff:a.b.c.d), failing closed on DNS resolution errors or an empty answer. 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 — referenced per the task's suggestion to reuse that approach. This module's public surface differs because sqlEndpoint is a bare host[:port] connection target, not a fetchable URL, so extractConnectionHostname parses that shape instead of new URL(...). The DNS lookup is injectable (mirrors the adapter's existing fetchImpl DI pattern), so tests are hermetic.
  • src/adapters/fabric-onelake-adapter.ts: getPool() now calls assertConnectionHostIsSafe(sqlEndpoint, ...) before loadDriver()/getToken()/constructing the pool — a blocked target never reaches a real outbound connection attempt or spends an AAD token exchange. Constructor gained an optional dnsLookupImpl param (same pattern as fetchImpl), threaded through createFabricOneLakeAdapter() too. No behavior change for poolConfig.server itself (still stripScheme(sqlEndpoint), unchanged).

No allowlist of specific Fabric/OneLake hostnames was added — Fabric/OneLake SQL analytics endpoints are tenant-specific ({workspace}.datawarehouse.fabric.microsoft.com, ids vary per customer), so a blocklist of private ranges is the correct shape here, matching the adapter's existing "nothing hardcoded, always caller-supplied" design (see the adapter's own file header) and matching press0's approach as suggested.

Tests

  • tests/p0/fabric-onelake-ssrf-guard.test.ts (new): unit-tests the guard module directly — every blocked-range boundary (incl. the 172.16.0.0/12 edges and the cloud-metadata address specifically), the IPv4-mapped-IPv6 bypass form, extractConnectionHostname's host/port/IPv6-literal parsing, and fail-closed behavior on DNS resolution failure / empty answer / empty hostname.
  • tests/p0/fabric-onelake-adapter.test.ts (updated): new describe block proves (a) a private-IP-resolving sqlEndpoint is rejected via SsrfBlockedHostError before fetch (AAD token exchange) or the TDS pool's connect() is ever called, (b) a literal loopback sqlEndpoint (127.0.0.1) is rejected the same way, and (c) a normal external Fabric hostname still passes the guard and the query completes normally — proving the guard doesn't false-block legitimate targets. Existing tests in this file are updated to inject a deterministic DNS resolver (instead of relying on their fixture hostnames' real DNS records) so the suite stays hermetic.

Verification (this session, real runs — not asserted)

pnpm test        -> Test Files 74 passed | 22 skipped (96); Tests 997 passed | 184 skipped (1181); 0 failed
pnpm typecheck   -> clean (tsc -p tsconfig.check.json)
eslint on the 4 touched/added files -> 0 problems

The 184 skipped tests are pre-existing .realinfra.test.ts files that self-skip honestly when their live backend (Postgres/ST0RE/etc.) isn't reachable from this environment — not related to this change. Pre-existing repo-wide eslint/prettier debt (unrelated files, ~150 files already not prettier-clean against the repo's own .prettierrc) is untouched by this PR — out of scope.

Not in scope

  • Payment/billing logic — untouched.
  • The mssql driver itself is still not a declared dependency (per the adapter's existing file header) — unrelated to this fix, not changed here.

Not merged/self-approved — opening for review per the safety constraints for this task.

🤖 Generated with Claude Code

## What `connection_config.sqlEndpoint` on `POST /api/0pt/sources/{sourceId}/query` (`source-query-service.ts` -> `fabric-onelake-adapter.ts`) was passed straight from the request body to the `mssql` TDS driver as the outbound connection target, with only its `http(s)://` scheme stripped (`stripScheme()`). No hostname allowlist or private-IP/internal-network guard existed, so a caller-supplied `sqlEndpoint` could point this backend's outbound SQL connection at `127.0.0.1`, an RFC1918 address, or the cloud-metadata link-local address (`169.254.169.254`). This route already requires a valid Zitadel bearer token + tenant match (the P014 tenant guard, `src/http/tenant-guard.ts`), so it was **not anonymously exploitable today** — this is defense-in-depth, closing the exact SSRF shape found and exploited-in-principle elsewhere in this estate tonight (press0's fulltext-ingestion fetch path). ## Change - **`src/lib/security/ssrf-guard.ts`** (new): resolves the endpoint's hostname and rejects any resolved address in a private/loopback/link-local/cloud-metadata/unspecified range (RFC1918, `127.0.0.0/8`, `169.254.0.0/16` incl. `169.254.169.254`, `::1`, `fc00::/7`, `fe80::/10`, and the IPv4-mapped-IPv6 bypass form `::ffff:a.b.c.d`), failing closed on DNS resolution errors or an empty answer. 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 — referenced per the task's suggestion to reuse that approach. This module's public surface differs because `sqlEndpoint` is a bare `host[:port]` connection target, not a fetchable URL, so `extractConnectionHostname` parses that shape instead of `new URL(...)`. The DNS lookup is injectable (mirrors the adapter's existing `fetchImpl` DI pattern), so tests are hermetic. - **`src/adapters/fabric-onelake-adapter.ts`**: `getPool()` now calls `assertConnectionHostIsSafe(sqlEndpoint, ...)` before `loadDriver()`/`getToken()`/constructing the pool — a blocked target never reaches a real outbound connection attempt or spends an AAD token exchange. Constructor gained an optional `dnsLookupImpl` param (same pattern as `fetchImpl`), threaded through `createFabricOneLakeAdapter()` too. No behavior change for `poolConfig.server` itself (still `stripScheme(sqlEndpoint)`, unchanged). No allowlist of specific Fabric/OneLake hostnames was added — Fabric/OneLake SQL analytics endpoints are tenant-specific (`{workspace}.datawarehouse.fabric.microsoft.com`, ids vary per customer), so a blocklist of private ranges is the correct shape here, matching the adapter's existing "nothing hardcoded, always caller-supplied" design (see the adapter's own file header) and matching press0's approach as suggested. ## Tests - **`tests/p0/fabric-onelake-ssrf-guard.test.ts`** (new): unit-tests the guard module directly — every blocked-range boundary (incl. the 172.16.0.0/12 edges and the cloud-metadata address specifically), the IPv4-mapped-IPv6 bypass form, `extractConnectionHostname`'s host/port/IPv6-literal parsing, and fail-closed behavior on DNS resolution failure / empty answer / empty hostname. - **`tests/p0/fabric-onelake-adapter.test.ts`** (updated): new `describe` block proves (a) a private-IP-resolving `sqlEndpoint` is rejected via `SsrfBlockedHostError` **before** `fetch` (AAD token exchange) or the TDS pool's `connect()` is ever called, (b) a literal loopback `sqlEndpoint` (`127.0.0.1`) is rejected the same way, and (c) a normal external Fabric hostname still passes the guard and the query completes normally — proving the guard doesn't false-block legitimate targets. Existing tests in this file are updated to inject a deterministic DNS resolver (instead of relying on their fixture hostnames' real DNS records) so the suite stays hermetic. ## Verification (this session, real runs — not asserted) ``` pnpm test -> Test Files 74 passed | 22 skipped (96); Tests 997 passed | 184 skipped (1181); 0 failed pnpm typecheck -> clean (tsc -p tsconfig.check.json) eslint on the 4 touched/added files -> 0 problems ``` The 184 skipped tests are pre-existing `.realinfra.test.ts` files that self-skip honestly when their live backend (Postgres/ST0RE/etc.) isn't reachable from this environment — not related to this change. Pre-existing repo-wide `eslint`/`prettier` debt (unrelated files, ~150 files already not prettier-clean against the repo's own `.prettierrc`) is untouched by this PR — out of scope. ## Not in scope - Payment/billing logic — untouched. - The `mssql` driver itself is still not a declared dependency (per the adapter's existing file header) — unrelated to this fix, not changed here. Not merged/self-approved — opening for review per the safety constraints for this task. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 1 commit 2026-09-11 00:19:48 +00:00
fix(security): add SSRF guard to Fabric/OneLake adapter's sqlEndpoint
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 27s
CI (SIAX Cloud) / quality (push) Successful in 33s
CI (SIAX Cloud) / integration (push) Successful in 46s
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m11s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 35s
4caf122129
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 <noreply@anthropic.com>
CODEOWNERS rules requested review from sax3l 2026-09-11 00:19:48 +00:00
Author
Owner

Continuous-merge sweep: PR:s blockerande kontexter är grön-ekvivalenta med main — main är redan röd på CI (SIAX Cloud) / integration (pre-existing estate-red). Mergad enligt sweepregel 4; estate-reden åtgärdas separat mot main.

Continuous-merge sweep: PR:s blockerande kontexter är grön-ekvivalenta med main — main är redan röd på CI (SIAX Cloud) / integration (pre-existing estate-red). Mergad enligt sweepregel 4; estate-reden åtgärdas separat mot main.
siax-bot approved these changes 2026-09-13 15:39:20 +00:00
siax-bot left a comment
Collaborator

siax-bot CI-verifiering (continuous-merge sweep)

siax-bot CI-verifiering (continuous-merge sweep)
admin merged commit into main 2026-09-13 15:39:21 +00:00
admin deleted branch fix/fabric-onelake-ssrf-guard 2026-09-13 15:39:21 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/0pt#54