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 SsrfBlockedHostErrorbeforefetch (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.
## 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)
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
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
connection_config.sqlEndpointonPOST /api/0pt/sources/{sourceId}/query(source-query-service.ts->fabric-onelake-adapter.ts) was passed straight from the request body to themssqlTDS driver as the outbound connection target, with only itshttp(s)://scheme stripped (stripScheme()). No hostname allowlist or private-IP/internal-network guard existed, so a caller-suppliedsqlEndpointcould point this backend's outbound SQL connection at127.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/16incl.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'ssrc/lib/security/ssrf-guard.ts, built the same night for the same bug class against afetch()-based URL there — referenced per the task's suggestion to reuse that approach. This module's public surface differs becausesqlEndpointis a barehost[:port]connection target, not a fetchable URL, soextractConnectionHostnameparses that shape instead ofnew URL(...). The DNS lookup is injectable (mirrors the adapter's existingfetchImplDI pattern), so tests are hermetic.src/adapters/fabric-onelake-adapter.ts:getPool()now callsassertConnectionHostIsSafe(sqlEndpoint, ...)beforeloadDriver()/getToken()/constructing the pool — a blocked target never reaches a real outbound connection attempt or spends an AAD token exchange. Constructor gained an optionaldnsLookupImplparam (same pattern asfetchImpl), threaded throughcreateFabricOneLakeAdapter()too. No behavior change forpoolConfig.serveritself (stillstripScheme(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): newdescribeblock proves (a) a private-IP-resolvingsqlEndpointis rejected viaSsrfBlockedHostErrorbeforefetch(AAD token exchange) or the TDS pool'sconnect()is ever called, (b) a literal loopbacksqlEndpoint(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)
The 184 skipped tests are pre-existing
.realinfra.test.tsfiles 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-wideeslint/prettierdebt (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
mssqldriver 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
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>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 CI-verifiering (continuous-merge sweep)