fix(security): dial-time SSRF guard on shared OIDC HTTP client (bookshelf-qapga) #1405

Merged
zombor merged 5 commits from bd-bookshelf-qapga into main 2026-08-09 14:00:24 +00:00
Owner

Summary

Follow-up to #1396 (bookshelf-qapga). Ports cover's dial-time SSRF guard onto
internal/users' shared oidcHTTPClient to close a DNS-rebinding/TOCTOU gap: a
hostname-based jwks_uri (or any OIDC discovery/JWKS request) that resolves
to a private/loopback/metadata IP only at connect time was not caught by the
existing pre-fetch validateFetchableDiscoveryURL host check.

  • Extracted cover's safeDialContext/safeDialContextWithResolver/safeTransport/isPrivateIP into a new shared internal/netguard package (resolve hostname once, validate every returned IP, dial the validated literal directly — no re-resolution TOCTOU). internal/cover/download.go now delegates to it with no behavior change.
  • Wired netguard.SafeTransport() onto oidcHTTPClient (internal/users/oidc_jwks.go). Since DiscoverOIDCMeta/FetchJWKS share this one client, the fix applies symmetrically to both the admin Test Connection diagnostic and the production login-time JWKS fetch (newOIDCJWKSCache -> FetchJWKS, wired in wire.go) — the login path previously had no host-validation guard at all.
  • Test compatibility: oidcHTTPClient is also used for real OAuth2 discovery/token/userinfo against trusted issuers, and most existing tests point it at httptest.Server on 127.0.0.1 via the existing SetOIDCHTTPClient escape hatch. users_suite_test.go now installs a package-wide test-mode client (plain transport, same CheckRedirect policy) via a global BeforeEach, while capturing the real production client in BeforeSuite. A new oidc_ssrf_transport_test.go re-installs that captured production client to prove the loopback/private-IP block, the DNS-rebinding-shaped case (localhost), the real CheckRedirect closure, and that the escape hatch still works.

Extraction vs. inline port: chose extraction into internal/netguard (project convention favors DRY reuse over duplicating ~90 lines of dial-guard logic across two packages); the alternative (duplicating the guard directly into oidc_jwks.go) was rejected as it would need to be kept in sync with cover's copy by hand.

Test plan

  • internal/netguard new black-box suite, 100% coverage (rebinding-style resolver stub rejected, public-IP resolver allowed, CGNAT/loopback/link-local ranges, SafeTransport fallback path, SetOIDCHTTPClient escape hatch).
  • internal/cover existing suite passes unmodified (delegates via export_test.go aliases).
  • internal/users existing suite passes (1032+ specs) after installing the suite-wide test-mode client.
  • New oidc_ssrf_transport_test.go: production wiring rejects loopback IP literal, rejects a DNS-rebinding-shaped hostname, real CheckRedirect closure still refuses redirects, SetOIDCHTTPClient escape hatch still bypasses the guard.
  • make build, make test, make lint (scoped to changed packages — a stale sibling worktree pollutes the repo-wide golangci-lint run, unrelated to this change), ./scripts/check-coverage.sh all green locally.
  • Docs: N/A — internal security hardening, no user-facing surface.

Closes bead bookshelf-qapga on merge.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

## Summary Follow-up to #1396 (bookshelf-qapga). Ports cover's dial-time SSRF guard onto internal/users' shared `oidcHTTPClient` to close a DNS-rebinding/TOCTOU gap: a hostname-based `jwks_uri` (or any OIDC discovery/JWKS request) that resolves to a private/loopback/metadata IP only at connect time was not caught by the existing pre-fetch `validateFetchableDiscoveryURL` host check. - Extracted cover's `safeDialContext`/`safeDialContextWithResolver`/`safeTransport`/`isPrivateIP` into a new shared `internal/netguard` package (resolve hostname once, validate every returned IP, dial the validated literal directly — no re-resolution TOCTOU). `internal/cover/download.go` now delegates to it with no behavior change. - Wired `netguard.SafeTransport()` onto `oidcHTTPClient` (internal/users/oidc_jwks.go). Since `DiscoverOIDCMeta`/`FetchJWKS` share this one client, the fix applies symmetrically to **both** the admin Test Connection diagnostic and the production login-time JWKS fetch (`newOIDCJWKSCache -> FetchJWKS`, wired in `wire.go`) — the login path previously had no host-validation guard at all. - Test compatibility: `oidcHTTPClient` is also used for real OAuth2 discovery/token/userinfo against trusted issuers, and most existing tests point it at `httptest.Server` on 127.0.0.1 via the existing `SetOIDCHTTPClient` escape hatch. `users_suite_test.go` now installs a package-wide test-mode client (plain transport, same `CheckRedirect` policy) via a global `BeforeEach`, while capturing the real production client in `BeforeSuite`. A new `oidc_ssrf_transport_test.go` re-installs that captured production client to prove the loopback/private-IP block, the DNS-rebinding-shaped case (`localhost`), the real `CheckRedirect` closure, and that the escape hatch still works. **Extraction vs. inline port:** chose extraction into `internal/netguard` (project convention favors DRY reuse over duplicating ~90 lines of dial-guard logic across two packages); the alternative (duplicating the guard directly into `oidc_jwks.go`) was rejected as it would need to be kept in sync with cover's copy by hand. ## Test plan - [x] `internal/netguard` new black-box suite, 100% coverage (rebinding-style resolver stub rejected, public-IP resolver allowed, CGNAT/loopback/link-local ranges, `SafeTransport` fallback path, `SetOIDCHTTPClient` escape hatch). - [x] `internal/cover` existing suite passes unmodified (delegates via export_test.go aliases). - [x] `internal/users` existing suite passes (1032+ specs) after installing the suite-wide test-mode client. - [x] New `oidc_ssrf_transport_test.go`: production wiring rejects loopback IP literal, rejects a DNS-rebinding-shaped hostname, real `CheckRedirect` closure still refuses redirects, `SetOIDCHTTPClient` escape hatch still bypasses the guard. - [x] `make build`, `make test`, `make lint` (scoped to changed packages — a stale sibling worktree pollutes the repo-wide `golangci-lint` run, unrelated to this change), `./scripts/check-coverage.sh` all green locally. - [x] Docs: N/A — internal security hardening, no user-facing surface. Closes bead bookshelf-qapga on merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix(security): dial-time SSRF guard on shared OIDC HTTP client (bookshelf-qapga)
Some checks failed
/ Test Race (pull_request) Successful in 1m58s
/ E2E API (pull_request) Failing after 1m42s
/ JS Unit Tests (pull_request) Successful in 54s
/ Lint (pull_request) Successful in 3m7s
/ Coverage (pull_request) Successful in 2m17s
/ Integration (pull_request) Successful in 2m12s
/ E2E Browser (pull_request) Successful in 4m45s
02378b0444
Extracts cover's dial-time SSRF guard (resolve hostname once, validate every
returned IP against a private/loopback/reserved denylist, dial the validated
IP literal directly) into a new shared internal/netguard package, and wires
netguard.SafeTransport() onto internal/users' oidcHTTPClient. This closes the
DNS-rebinding/TOCTOU gap flagged as a follow-up in PR #1396: a hostname-based
jwks_uri that only resolves to a private/metadata IP at connect time (not at
the pre-fetch validateFetchableDiscoveryURL check) is now rejected.

Because oidcHTTPClient is shared by DiscoverOIDCMeta and FetchJWKS, the fix
applies symmetrically to both the admin Test Connection diagnostic AND the
production login-time JWKS fetch (newOIDCJWKSCache -> FetchJWKS, wire.go),
which previously had no host-validation guard at all.

internal/cover/download.go now delegates to netguard (ErrPrivateAddress,
isPrivateIP, safeDialContext(WithResolver), safeTransport all forward to the
shared package) with no behavior change — its existing test suite still
passes unmodified via the export_test.go aliases.

Test compatibility: oidcHTTPClient is also used for real OAuth2 discovery/
token/userinfo requests, and most existing tests already point it at
httptest.Server on 127.0.0.1 via SetOIDCHTTPClient. users_suite_test.go now
installs a package-wide test-mode client (plain transport, same CheckRedirect
policy) via a global BeforeEach so the rest of the suite keeps working, while
capturing the real production client in BeforeSuite. oidc_ssrf_transport_test.go
re-installs that captured production client to prove: a loopback/private IP
literal is rejected, a DNS-rebinding-shaped hostname (localhost) is rejected,
the real CheckRedirect closure still refuses redirects, and the
SetOIDCHTTPClient escape hatch still works.

internal/netguard ships its own black-box test suite at 100% coverage
(rebinding-style resolver stub rejected, public-IP resolver allowed through,
CGNAT/loopback/link-local ranges, SafeTransport fallback path). Added to the
Makefile UNIT_PKGS and scripts/check-coverage.sh package lists.

Docs: N/A — internal security hardening with no user-facing surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix(security): exempt loopback from OIDC dial guard; fix e2e slow-dial regression
All checks were successful
/ JS Unit Tests (pull_request) Successful in 58s
/ E2E API (pull_request) Successful in 1m29s
/ Test Race (pull_request) Successful in 1m55s
/ Lint (pull_request) Successful in 2m15s
/ Coverage (pull_request) Successful in 2m23s
/ Integration (pull_request) Successful in 2m28s
/ E2E Browser (pull_request) Successful in 4m55s
1316b56915
CI's E2E API job (Journey-9: OIDC login with fake IdP) caught a real
regression: internal/users/oidc_jwks.go's oidcHTTPClient previously used
netguard.SafeTransport(), which blocks loopback unconditionally (matching
internal/cover's policy, where loopback is never legitimate). But
internal/settings' validateIssuerURI already has an accepted, intentional
carve-out permitting an admin-configured issuer at http://localhost (a
self-hosted local/dev IdP, e.g. e2e's fake in-process IdP or a
docker-compose Keycloak) — so unconditionally blocking loopback at the
transport layer broke that already-accepted deployment shape and made
POST /login 500 instead of redirecting to the fake IdP's /authorize.

Adds netguard.IsPrivateIPExceptLoopback / SafeDialContextAllowingLoopback /
SafeTransportAllowingLoopback (same guard, loopback exempted) and wires the
loopback-allowing variant onto oidcHTTPClient instead. RFC-1918, link-local
(including the 169.254.169.254 cloud metadata IP), CGNAT, unique-local, and
unspecified ranges remain blocked — only loopback is exempt, matching
validateIssuerURI's existing risk model exactly. internal/cover keeps the
stricter SafeTransport() (loopback blocked) unchanged.

Also fixes a slow-test regression in oidc_ssrf_transport_test.go: the escape-
hatch spec dialed an unreachable RFC-1918 address with no client Timeout,
which hung for the full ~30s OS TCP-connect timeout; added a short Timeout so
the spec fails fast on the expected non-blocking-guard error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Author
Owner

Security Review — PR #1405 (bd-bookshelf-qapga)

Reviewed both commits (02378b044 dial-time SSRF guard, 1316b5691 loopback exemption fix) against the diff base c833c0e23.

TOCTOU / DNS-rebinding closure: Confirmed closed. netguard.dialWithResolverAndBlocklist resolves the hostname exactly once via the injected resolver, validates every returned net.IPAddr against the blocklist, then dials net.JoinHostPort(addrs[0].IP.String(), port) — the validated IP literal, never the hostname. Verified no code path re-passes the original hostname to the dialer. Redirects: internal/cover's safeCheckRedirect allows up to 5 hops but every redirect's TCP dial goes back through the same SafeDialContext-backed transport (validated per-hop, since http.Transport.DialContext is invoked again on every redirect connection) — good. OIDC's oidcHTTPClient.CheckRedirect refuses all redirects outright, closing the redirect-to-internal-target vector entirely for OIDC. Confirmed via git diff that production login (newOIDCJWKSCache → FetchJWKS, wired in wire.go:184) and the admin Test Connection diagnostic (DiscoverOIDCMeta/FetchJWKS) share the single oidcHTTPClient package var — so the production login-time JWKS fetch (the bead's key gap) genuinely gets the guard, not just the diagnostic. Before this PR, oidcHTTPClient had no Transport field set at all (plain zero-value http.Client{}, i.e. http.DefaultTransport, fully unguarded) — confirmed via diff.

Blocklist completeness: Verified programmatically (net.ParseCIDR/net.IP behavior) that IPv4-mapped IPv6 addresses correctly collapse via net.IP.To4() before both net.IPNet.Contains and net.IP.IsLinkLocalUnicast()/IsPrivate(), so ::ffff:169.254.169.254 and ::ffff:10.1.1.1 are correctly caught (ipnet.Contains and IsPrivate both returned true in a standalone repro). RFC1918, loopback (v4+v6), link-local incl. metadata 169.254.169.254, CGNAT 100.64.0.0/10, IPv6 ULA fc00::/7, ::1, 0.0.0.0/8, ::/128 are all present in privateRanges + stdlib checks. No missing range found.

Loopback exemption (OIDC only): IsPrivateIPExceptLoopback exempts ONLY loopback (!ip.IsLoopback() && IsPrivateIP(ip)) — RFC-1918, link-local/metadata, CGNAT, ULA, unspecified remain blocked for OIDC. internal/cover continues to use the strict netguard.SafeTransport() (no exemption) — confirmed cover/download.go does not reference the AllowingLoopback variants. The exemption is justified in-repo by internal/settings.validateIssuerURI's pre-existing accepted carve-out for a self-hosted http://localhost IdP, and is exercised end-to-end by the e2e fake-IdP journey per the commit message.

Escape hatch: SetOIDCHTTPClient lives only in internal/users/export_test.go, a _test.go file excluded from the production binary by the Go toolchain — not reachable in prod.

Test coverage: internal/netguard is properly wired into UNIT_PKGS (Makefile) and check-coverage.sh. oidc_ssrf_transport_test.go exercises both the RFC-1918/metadata-IP rejection and the loopback-allow path against the real production client, plus the CheckRedirect closure and the escape hatch.

One documentation defect found:

[MINOR] internal/settings/oidc_settings.go:590 — stale comment says wrong transport function
The comment states "oidcHTTPClient ... is wired with netguard.SafeTransport()", but per the second commit (1316b5691) the actual wiring is netguard.SafeTransportAllowingLoopback(). This comment was written in the first commit (02378b044) and not updated when the loopback exemption was added in the follow-up commit, so it now misstates the security posture at the exact point most likely to be read by a future reviewer evaluating validateIssuerURI's risk model (a reader could wrongly conclude loopback is blocked at the transport layer here too). Fix: update the comment to say SafeTransportAllowingLoopback() and note the loopback exemption, matching the accurate doc comment already present in internal/users/oidc_jwks.go.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Review — PR #1405 (bd-bookshelf-qapga) Reviewed both commits (02378b044 dial-time SSRF guard, 1316b5691 loopback exemption fix) against the diff base c833c0e23. **TOCTOU / DNS-rebinding closure:** Confirmed closed. `netguard.dialWithResolverAndBlocklist` resolves the hostname exactly once via the injected resolver, validates every returned `net.IPAddr` against the blocklist, then dials `net.JoinHostPort(addrs[0].IP.String(), port)` — the validated IP literal, never the hostname. Verified no code path re-passes the original hostname to the dialer. Redirects: `internal/cover`'s `safeCheckRedirect` allows up to 5 hops but every redirect's TCP dial goes back through the same `SafeDialContext`-backed transport (validated per-hop, since `http.Transport.DialContext` is invoked again on every redirect connection) — good. OIDC's `oidcHTTPClient.CheckRedirect` refuses all redirects outright, closing the redirect-to-internal-target vector entirely for OIDC. Confirmed via `git diff` that production login (`newOIDCJWKSCache → FetchJWKS`, wired in `wire.go:184`) and the admin Test Connection diagnostic (`DiscoverOIDCMeta`/`FetchJWKS`) share the single `oidcHTTPClient` package var — so the production login-time JWKS fetch (the bead's key gap) genuinely gets the guard, not just the diagnostic. Before this PR, `oidcHTTPClient` had no `Transport` field set at all (plain zero-value `http.Client{}`, i.e. `http.DefaultTransport`, fully unguarded) — confirmed via diff. **Blocklist completeness:** Verified programmatically (`net.ParseCIDR`/`net.IP` behavior) that IPv4-mapped IPv6 addresses correctly collapse via `net.IP.To4()` before both `net.IPNet.Contains` and `net.IP.IsLinkLocalUnicast()`/`IsPrivate()`, so `::ffff:169.254.169.254` and `::ffff:10.1.1.1` are correctly caught (`ipnet.Contains` and `IsPrivate` both returned `true` in a standalone repro). RFC1918, loopback (v4+v6), link-local incl. metadata 169.254.169.254, CGNAT 100.64.0.0/10, IPv6 ULA fc00::/7, ::1, 0.0.0.0/8, ::/128 are all present in `privateRanges` + stdlib checks. No missing range found. **Loopback exemption (OIDC only):** `IsPrivateIPExceptLoopback` exempts ONLY loopback (`!ip.IsLoopback() && IsPrivateIP(ip)`) — RFC-1918, link-local/metadata, CGNAT, ULA, unspecified remain blocked for OIDC. `internal/cover` continues to use the strict `netguard.SafeTransport()` (no exemption) — confirmed cover/download.go does not reference the `AllowingLoopback` variants. The exemption is justified in-repo by `internal/settings.validateIssuerURI`'s pre-existing accepted carve-out for a self-hosted `http://localhost` IdP, and is exercised end-to-end by the e2e fake-IdP journey per the commit message. **Escape hatch:** `SetOIDCHTTPClient` lives only in `internal/users/export_test.go`, a `_test.go` file excluded from the production binary by the Go toolchain — not reachable in prod. **Test coverage:** `internal/netguard` is properly wired into `UNIT_PKGS` (Makefile) and `check-coverage.sh`. `oidc_ssrf_transport_test.go` exercises both the RFC-1918/metadata-IP rejection and the loopback-allow path against the real production client, plus the CheckRedirect closure and the escape hatch. One documentation defect found: [MINOR] internal/settings/oidc_settings.go:590 — stale comment says wrong transport function The comment states "oidcHTTPClient ... is wired with netguard.SafeTransport()", but per the second commit (1316b5691) the actual wiring is `netguard.SafeTransportAllowingLoopback()`. This comment was written in the first commit (02378b044) and not updated when the loopback exemption was added in the follow-up commit, so it now misstates the security posture at the exact point most likely to be read by a future reviewer evaluating `validateIssuerURI`'s risk model (a reader could wrongly conclude loopback is blocked at the transport layer here too). Fix: update the comment to say `SafeTransportAllowingLoopback()` and note the loopback exemption, matching the accurate doc comment already present in `internal/users/oidc_jwks.go`. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Code Review — PR #1405 (bd-bookshelf-qapga)

Phase 1 — Spec compliance

Extraction into internal/netguard is faithful (see Phase 2 below); wiring covers both DiscoverOIDCMeta/FetchJWKS call sites (Test Connection diagnostic + production newOIDCJWKSCache) since both share oidcHTTPClient. Docs: N/A is correctly justified (internal security hardening, no user-facing surface).

Phase 2 — Findings

[BLOCKER] internal/users/oidc_jwks.go:78 — loopback exemption reopens the exact discovery-derived-URL SSRF class this PR's sibling code says must stay closed
oidcHTTPClient is the single shared client for: (1) DiscoverOIDCMeta against the admin-configured, trusted issuer, AND (2) every subsequent IdP-supplied endpoint pulled out of that discovery document — jwks_uri (FetchJWKS, called from both checkOIDCJWKS in internal/settings/oidc_settings.go:805 and the production newOIDCJWKSCache → FetchJWKS path wired in internal/users/wire.go:95), plus token_endpoint/userinfo_endpoint. Those discovery-response fields are explicitly documented as untrusted/attacker-influenceable — internal/settings/oidc_settings.go:582-583: "discovery response fields are never admin-typed trusted config, so we hold them to the stricter rule", and validateFetchableDiscoveryURL's own doc-comment says "there is no http/loopback exception here".

But the actual backstop for hostname-based (non-literal-IP) discovery-response URLs — which validateFetchableDiscoveryURL explicitly defers to the transport layer for (oidc_settings.go:583-584, "Hostnames that are not raw IP literals are allowed through this check (DNS resolution happens at fetch time)") — is netguard.SafeTransportAllowingLoopback() (oidc_jwks.go:78), which does carve out a loopback exception (IsPrivateIPExceptLoopback). So a malicious or compromised IdP can return jwks_uri (or token_endpoint/userinfo_endpoint) as a hostname that resolves to 127.0.0.1/::1, and:

  • The pre-fetch validateFetchableDiscoveryURL check does not catch it (only literal IPs are checked at that layer).
  • The transport-layer guard, which the pre-fetch check's own doc-comment says is where hostname-based SSRF "is mitigated," does not catch it either — loopback is deliberately allowed through.

Net effect: an IdP the admin trusts as an OIDC provider can pivot an SSRF into the pergamum host's own loopback-bound services (e.g. /debug/pprof, /metrics, any other localhost-only admin surface) via a crafted jwks_uri/token_endpoint/userinfo_endpoint — every login, not just the Test Connection diagnostic, since the production path (newOIDCJWKSCache → FetchJWKS) has no pre-fetch host check at all and relies 100% on this transport.

This is exactly the failure mode validateIssuerURI's loopback carve-out was scoped to avoid extending — it exists only for the admin-typed, trusted issuer URL, not for provider-supplied response fields. The extraction/wiring in this PR silently widened that carve-out to cover every request the shared client makes.

Fix: don't reuse one loopback-permissive client for both trust levels. Options: (a) use netguard.SafeTransport() (no loopback exception) for FetchJWKS/token/userinfo requests and reserve SafeTransportAllowingLoopback() only for the initial DiscoverOIDCMeta request to the admin-configured issuer; (b) split oidcHTTPClient into two clients (discovery-only allows loopback, everything IdP-supplied does not); (c) at minimum, if the local-self-hosted-IdP scenario is expected to also serve JWKS/token/userinfo on loopback (which it plausibly does, since it's the same host), validate that the resolved IP for jwks_uri/etc. matches the resolved IP of the already-validated issuer, not merely "any loopback address is fine."

[MINOR] internal/settings/oidc_settings.go:590-591 / internal/users/oidc_jwks.go:66,76-78 — doc comments say netguard.SafeTransport(), code wires netguard.SafeTransportAllowingLoopback()
Both the updated doc-comment in validateFetchableDiscoveryURL ("is wired with netguard.SafeTransport()...") and the PR description ("Wired netguard.SafeTransport() onto oidcHTTPClient") describe the strict (no-loopback) helper, while the code actually installs SafeTransportAllowingLoopback(). This is a symptom of the BLOCKER above — the comment describes the behavior the authors intended/believed they shipped. Once the BLOCKER is fixed, reconcile the comment with whichever transport is actually used at each call site.

What's solid

  • internal/cover/download.go extraction is a true behavior-preserving move: ErrPrivateAddress, privateRanges (all 11 CIDRs incl. CGNAT/this-network), isPrivateIP's exact boolean logic, the IP-literal fast path, the resolve-once/validate-every-IP/dial-the-literal flow, the len(addrs)==0 guard, and safeTransport's http.DefaultTransport clone-and-fallback are all present verbatim in internal/netguard/dial.go, just renamed to exported symbols. Cover's own test suite (download_test.go, export_test.go) is untouched in the diff and still compiles/passes via the new local var-aliases (internal/cover/download.go:39-44), confirming zero behavior drift on the cover path.
  • Cover's own transport correctly stays on the strict netguard.SafeTransport()/SafeDialContext (no loopback exemption) — the loopback carve-out is scoped only to the OIDC variant, as intended for the cover path.
  • internal/netguard/dial_test.go is properly black-box (package netguard_test), uses DescribeTable/It with one assertion per It, and exercises all documented branches (CIDR panics, empty-resolver-result, mixed-IP-list, DNS-rebinding-shaped resolver stub, SafeTransport's non-*http.Transport fallback).
  • internal/users/export_test.go's new ExportOIDCHTTPClient is consistent with the pre-existing, allowlisted white-box exception for internal/users (nnb9.30 black-box conversion still pending; file is already in scripts/test_policy_check/allowlist.txt and the diff doesn't touch the allowlist). The new spec file oidc_ssrf_transport_test.go itself is proper black-box (package users_test), using only exported SetOIDCHTTPClient/ExportOIDCHTTPClient/DiscoverOIDCMeta/FetchJWKS.
  • Makefile UNIT_PKGS and scripts/check-coverage.sh both add ./internal/netguard/... — coverage wiring is correct.

REVIEW VERDICT: 1 blocker, 0 major, 1 minor

## Code Review — PR #1405 (bd-bookshelf-qapga) ### Phase 1 — Spec compliance Extraction into `internal/netguard` is faithful (see Phase 2 below); wiring covers both `DiscoverOIDCMeta`/`FetchJWKS` call sites (Test Connection diagnostic + production `newOIDCJWKSCache`) since both share `oidcHTTPClient`. Docs: N/A is correctly justified (internal security hardening, no user-facing surface). ### Phase 2 — Findings [BLOCKER] internal/users/oidc_jwks.go:78 — loopback exemption reopens the exact discovery-derived-URL SSRF class this PR's sibling code says must stay closed `oidcHTTPClient` is the **single shared client** for: (1) `DiscoverOIDCMeta` against the admin-configured, trusted issuer, AND (2) every subsequent IdP-*supplied* endpoint pulled out of that discovery document — `jwks_uri` (`FetchJWKS`, called from both `checkOIDCJWKS` in internal/settings/oidc_settings.go:805 and the production `newOIDCJWKSCache → FetchJWKS` path wired in internal/users/wire.go:95), plus `token_endpoint`/`userinfo_endpoint`. Those discovery-response fields are explicitly documented as untrusted/attacker-influenceable — `internal/settings/oidc_settings.go:582-583`: *"discovery response fields are never admin-typed trusted config, so we hold them to the stricter rule"*, and `validateFetchableDiscoveryURL`'s own doc-comment says *"there is no http/loopback exception here"*. But the actual backstop for hostname-based (non-literal-IP) discovery-response URLs — which `validateFetchableDiscoveryURL` explicitly defers to the transport layer for (oidc_settings.go:583-584, "Hostnames that are not raw IP literals are allowed through this check (DNS resolution happens at fetch time)") — is `netguard.SafeTransportAllowingLoopback()` (oidc_jwks.go:78), which **does** carve out a loopback exception (`IsPrivateIPExceptLoopback`). So a malicious or compromised IdP can return `jwks_uri` (or `token_endpoint`/`userinfo_endpoint`) as a hostname that resolves to `127.0.0.1`/`::1`, and: - The pre-fetch `validateFetchableDiscoveryURL` check does not catch it (only literal IPs are checked at that layer). - The transport-layer guard, which the pre-fetch check's own doc-comment says is where hostname-based SSRF "is mitigated," does not catch it either — loopback is deliberately allowed through. Net effect: an IdP the admin trusts *as an OIDC provider* can pivot an SSRF into the pergamum host's own loopback-bound services (e.g. `/debug/pprof`, `/metrics`, any other localhost-only admin surface) via a crafted `jwks_uri`/`token_endpoint`/`userinfo_endpoint` — every login, not just the Test Connection diagnostic, since the production path (`newOIDCJWKSCache → FetchJWKS`) has **no** pre-fetch host check at all and relies 100% on this transport. This is exactly the failure mode `validateIssuerURI`'s loopback carve-out was scoped to *avoid* extending — it exists only for the admin-typed, trusted issuer URL, not for provider-supplied response fields. The extraction/wiring in this PR silently widened that carve-out to cover every request the shared client makes. Fix: don't reuse one loopback-permissive client for both trust levels. Options: (a) use `netguard.SafeTransport()` (no loopback exception) for `FetchJWKS`/token/userinfo requests and reserve `SafeTransportAllowingLoopback()` only for the initial `DiscoverOIDCMeta` request to the admin-configured issuer; (b) split `oidcHTTPClient` into two clients (discovery-only allows loopback, everything IdP-supplied does not); (c) at minimum, if the local-self-hosted-IdP scenario is expected to also serve JWKS/token/userinfo on loopback (which it plausibly does, since it's the same host), validate that the resolved IP for `jwks_uri`/etc. matches the resolved IP of the already-validated issuer, not merely "any loopback address is fine." [MINOR] internal/settings/oidc_settings.go:590-591 / internal/users/oidc_jwks.go:66,76-78 — doc comments say `netguard.SafeTransport()`, code wires `netguard.SafeTransportAllowingLoopback()` Both the updated doc-comment in `validateFetchableDiscoveryURL` ("is wired with netguard.SafeTransport()...") and the PR description ("Wired `netguard.SafeTransport()` onto `oidcHTTPClient`") describe the *strict* (no-loopback) helper, while the code actually installs `SafeTransportAllowingLoopback()`. This is a symptom of the BLOCKER above — the comment describes the behavior the authors intended/believed they shipped. Once the BLOCKER is fixed, reconcile the comment with whichever transport is actually used at each call site. ### What's solid - `internal/cover/download.go` extraction is a true behavior-preserving move: `ErrPrivateAddress`, `privateRanges` (all 11 CIDRs incl. CGNAT/this-network), `isPrivateIP`'s exact boolean logic, the IP-literal fast path, the resolve-once/validate-every-IP/dial-the-literal flow, the `len(addrs)==0` guard, and `safeTransport`'s `http.DefaultTransport` clone-and-fallback are all present verbatim in `internal/netguard/dial.go`, just renamed to exported symbols. Cover's own test suite (`download_test.go`, `export_test.go`) is untouched in the diff and still compiles/passes via the new local var-aliases (`internal/cover/download.go:39-44`), confirming zero behavior drift on the cover path. - Cover's own transport correctly stays on the strict `netguard.SafeTransport()`/`SafeDialContext` (no loopback exemption) — the loopback carve-out is scoped only to the OIDC variant, as intended for the cover path. - `internal/netguard/dial_test.go` is properly black-box (`package netguard_test`), uses `DescribeTable`/`It` with one assertion per `It`, and exercises all documented branches (CIDR panics, empty-resolver-result, mixed-IP-list, DNS-rebinding-shaped resolver stub, `SafeTransport`'s non-`*http.Transport` fallback). - `internal/users/export_test.go`'s new `ExportOIDCHTTPClient` is consistent with the pre-existing, allowlisted white-box exception for `internal/users` (nnb9.30 black-box conversion still pending; file is already in `scripts/test_policy_check/allowlist.txt` and the diff doesn't touch the allowlist). The new spec file `oidc_ssrf_transport_test.go` itself is proper black-box (`package users_test`), using only exported `SetOIDCHTTPClient`/`ExportOIDCHTTPClient`/`DiscoverOIDCMeta`/`FetchJWKS`. - `Makefile` UNIT_PKGS and `scripts/check-coverage.sh` both add `./internal/netguard/...` — coverage wiring is correct. REVIEW VERDICT: 1 blocker, 0 major, 1 minor
fix(security): block loopback for untrusted OIDC discovery endpoints unless issuer is loopback (review BLOCKER, bookshelf-qapga)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m26s
/ E2E API (pull_request) Successful in 1m31s
/ Test Race (pull_request) Successful in 1m55s
/ Lint (pull_request) Successful in 2m13s
/ Coverage (pull_request) Failing after 2m20s
/ Integration (pull_request) Successful in 2m23s
/ E2E Browser (pull_request) Has been cancelled
34a08b9265
The shared oidcHTTPClient was wired with SafeTransportAllowingLoopback()
and used for BOTH the trusted, admin-configured issuer AND the
IdP-supplied (untrusted) jwks_uri/token_endpoint/userinfo_endpoint. A
malicious or compromised IdP could return one of those endpoints with a
hostname resolving to loopback, pivoting SSRF into pergamum's own
loopback-bound services on every login.

Split into two clients: oidcHTTPClient (strict, loopback blocked,
default) and oidcHTTPClientLoopback (loopback allowed), selected via
oidcClientForIssuer(issuer) — the loopback-allowing client is used only
when the trusted, admin-configured issuer is itself a loopback host,
preserving the existing local/dev self-hosted-IdP exception and the e2e
in-process fake IdP on 127.0.0.1. The existing SetOIDCHTTPClient test
escape hatch still overrides unconditionally (pointer-inequality check).

Threaded the trusted issuer through FetchJWKS, fetchUserinfoClaims,
oidcExchangeCode (token endpoint), the JWKS caches (oidcJWKSCache,
backchannel-logout jwksCache), and the internal/settings Test Connection
diagnostic so every untrusted-endpoint fetch can make the loopback
decision correctly.

Added tests: malicious IdP (public issuer + loopback jwks_uri) blocked,
local IdP (loopback issuer + loopback jwks_uri) allowed, metadata/RFC-1918
still blocked regardless of issuer, "localhost" hostname loopback
detection, and an unparseable-issuer fail-closed case. Fixed stale
comments in oidc_jwks.go and internal/settings/oidc_settings.go that
claimed the strict transport was already in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi
fix(coverage): share refuseOIDCRedirect closure between both OIDC clients
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m22s
/ E2E API (pull_request) Successful in 1m27s
/ Test Race (pull_request) Successful in 2m8s
/ Integration (pull_request) Successful in 2m14s
/ Coverage (pull_request) Successful in 2m23s
/ Lint (pull_request) Successful in 2m52s
/ E2E Browser (pull_request) Successful in 4m18s
5e75a7c249
The two CheckRedirect literals (one per client) each counted as a
separate uncovered statement block under the 100% gate even though only
one is exercised by the existing redirect-refusal test. Extracting a
single shared refuseOIDCRedirect function used by both clients means
covering either client's redirect path covers both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi
Author
Owner

Security RE-REVIEW — PR #1405 (bd-bookshelf-qapga), SHA 5e75a7c2

Verifying the fix for the BLOCKER in comment #17278 (loopback-exemption pivot via discovery-supplied jwks_uri/token_endpoint/userinfo_endpoint).

Verification performed

  1. Issuer provenance at every threaded call site — traced all uses of oidcClientForIssuer(issuer):

    • DiscoverOIDCMeta(ctx, issuer) → issuer is the raw admin-configured value (from TestOIDCConnectionResult's issuerURI param, or cfg.Issuer in the login callback). Never meta.Issuer.
    • FetchJWKS(ctx, jwksURI, issuer) → every caller passes cfg.Issuer (oidcVerifyAndProvision, fetchBackchannelJWKS) or the validated issuerURI (checkOIDCJWKS in internal/settings/oidc_settings.go), never meta.JWKSURI/discovery-derived data.
    • oidcExchangeCode(ctx, ..., issuer, ...) → called with cfg.Issuer (internal/users/oidc_service.go).
    • fetchUserinfoClaims(ctx, userinfoURL, ..., issuer, ...) → called with cfg.Issuer.
    • oidcJWKSCache.Fetch/Refresh(ctx, jwksURI, issuer) and the backchannel jwksCache.getJWKSissuer threaded through unchanged as cfg.Issuer, survives caching/singleflight keyed on jwksURI (not issuer, which is correct since jwksURI is the cache key and issuer is only used for the transport decision on each underlying fetch).
    • No call site passes a discovery-response field as the issuer argument. Gate is sound end-to-end.
  2. Threat matrix — verified via internal/users/oidc_ssrf_transport_test.go (real end-to-end specs, not mocks) plus source review of internal/netguard/dial.go:

    • PUBLIC issuer + loopback jwks_uri (IP literal 127.0.0.1 or hostname localhost) → BLOCKED (oidc_ssrf_transport_test.go: "blocks FetchJWKS when jwks_uri resolves to loopback").
    • Loopback issuer + loopback jwks_uri → allowed (self-hosted local IdP case preserved, matches validateIssuerURI's existing accepted exception).
    • Cloud metadata IP (169.254.169.254) → blocked regardless of issuer (loopback-allowing transport only exempts IsPrivateIPExceptLoopback, metadata IP is link-local, still denied).
    • DNS-rebinding: netguard.dialWithResolverAndBlocklist resolves the hostname exactly once, validates every returned IP, dials the validated literal directly (never re-resolves at connect time) — closes the TOCTOU window validateFetchableDiscoveryURL's doc comment explicitly defers to the transport layer.
    • Redirects are refused unconditionally (refuseOIDCRedirect, shared by both clients) — closes the "validated URL redirects to a restricted target" bypass.
  3. isLoopbackHost/isLoopbackIssuer (internal/users/oidc_jwks.go): handles "localhost" (case-insensitive), IP-literal loopback via net.ParseIP(h).IsLoopback() (covers 127.0.0.0/8, ::1, and IPv4-mapped IPv6 loopback since Go's IsLoopback checks To4() first). Fails CLOSED on an unparseable issuer (url.Parse error → isLoopbackIssuer returns false → strict client) and on an empty issuer (empty hostname matches neither branch → false → strict client). Verified by the "issuer is not a parseable URL... fails closed" spec.

  4. SetOIDCHTTPClient escape hatch: oidcClientForIssuer checks oidcHTTPClient != defaultOIDCHTTPClient (pointer inequality against the value captured at package init) and honors an override unconditionally when set — test-only, since production code never calls SetOIDCHTTPClient (it's declared in export_test.go). No production bypass introduced; confirmed no non-test file references SetOIDCHTTPClient.

  5. Test hygiene: internal/netguard/dial_test.go, netguard_suite_test.go, internal/users/oidc_ssrf_transport_test.go, internal/users/users_suite_test.go are all package <pkg>_test (black-box), no new .golangci.yml exclusions added. internal/users/export_test.go's new exports (ExportOIDCHTTPClient, updated ExportFetchJWKS/ExportDiscoverOIDCMeta signatures) are consistent with the pre-existing allowlisted white-box exception for that package (nnb9.30) and don't leak the SSRF gate itself (they expose the client/functions, not a way to force the loopback branch outside the documented issuer-based selection).

  6. Doc-comment MINOR from the original review — resolved: validateFetchableDiscoveryURL's comment (oidc_settings.go:589) now correctly describes netguard.SafeTransport() as the default with the loopback-allowing sibling scoped to a loopback issuer, matching the actual wiring.

Findings

None. The fix closes the BLOCKER: the loopback-allowing transport is now selected only when the trusted, admin-configured issuer is itself a loopback host, and every threaded issuer parameter traces back to admin-configured config, never a discovery-response field. CI is green (SHA 5e75a7c2, success) and the PR is mergeable.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security RE-REVIEW — PR #1405 (bd-bookshelf-qapga), SHA 5e75a7c2 Verifying the fix for the BLOCKER in comment #17278 (loopback-exemption pivot via discovery-supplied `jwks_uri`/`token_endpoint`/`userinfo_endpoint`). ### Verification performed 1. **Issuer provenance at every threaded call site** — traced all uses of `oidcClientForIssuer(issuer)`: - `DiscoverOIDCMeta(ctx, issuer)` → issuer is the raw admin-configured value (from `TestOIDCConnectionResult`'s `issuerURI` param, or `cfg.Issuer` in the login callback). Never `meta.Issuer`. - `FetchJWKS(ctx, jwksURI, issuer)` → every caller passes `cfg.Issuer` (`oidcVerifyAndProvision`, `fetchBackchannelJWKS`) or the validated `issuerURI` (`checkOIDCJWKS` in `internal/settings/oidc_settings.go`), never `meta.JWKSURI`/discovery-derived data. - `oidcExchangeCode(ctx, ..., issuer, ...)` → called with `cfg.Issuer` (`internal/users/oidc_service.go`). - `fetchUserinfoClaims(ctx, userinfoURL, ..., issuer, ...)` → called with `cfg.Issuer`. - `oidcJWKSCache.Fetch/Refresh(ctx, jwksURI, issuer)` and the backchannel `jwksCache.getJWKS` → `issuer` threaded through unchanged as `cfg.Issuer`, survives caching/singleflight keyed on `jwksURI` (not `issuer`, which is correct since jwksURI is the cache key and issuer is only used for the transport decision on each underlying fetch). - No call site passes a discovery-response field as the `issuer` argument. Gate is sound end-to-end. 2. **Threat matrix** — verified via `internal/users/oidc_ssrf_transport_test.go` (real end-to-end specs, not mocks) plus source review of `internal/netguard/dial.go`: - PUBLIC issuer + loopback `jwks_uri` (IP literal `127.0.0.1` or hostname `localhost`) → BLOCKED (`oidc_ssrf_transport_test.go`: "blocks FetchJWKS when jwks_uri resolves to loopback"). - Loopback issuer + loopback `jwks_uri` → allowed (self-hosted local IdP case preserved, matches `validateIssuerURI`'s existing accepted exception). - Cloud metadata IP (`169.254.169.254`) → blocked regardless of issuer (loopback-allowing transport only exempts `IsPrivateIPExceptLoopback`, metadata IP is link-local, still denied). - DNS-rebinding: `netguard.dialWithResolverAndBlocklist` resolves the hostname exactly once, validates every returned IP, dials the validated literal directly (never re-resolves at connect time) — closes the TOCTOU window `validateFetchableDiscoveryURL`'s doc comment explicitly defers to the transport layer. - Redirects are refused unconditionally (`refuseOIDCRedirect`, shared by both clients) — closes the "validated URL redirects to a restricted target" bypass. 3. **`isLoopbackHost`/`isLoopbackIssuer`** (`internal/users/oidc_jwks.go`): handles `"localhost"` (case-insensitive), IP-literal loopback via `net.ParseIP(h).IsLoopback()` (covers 127.0.0.0/8, `::1`, and IPv4-mapped IPv6 loopback since Go's `IsLoopback` checks `To4()` first). Fails CLOSED on an unparseable issuer (`url.Parse` error → `isLoopbackIssuer` returns `false` → strict client) and on an empty issuer (empty hostname matches neither branch → `false` → strict client). Verified by the "issuer is not a parseable URL... fails closed" spec. 4. **`SetOIDCHTTPClient` escape hatch**: `oidcClientForIssuer` checks `oidcHTTPClient != defaultOIDCHTTPClient` (pointer inequality against the value captured at package init) and honors an override unconditionally when set — test-only, since production code never calls `SetOIDCHTTPClient` (it's declared in `export_test.go`). No production bypass introduced; confirmed no non-test file references `SetOIDCHTTPClient`. 5. **Test hygiene**: `internal/netguard/dial_test.go`, `netguard_suite_test.go`, `internal/users/oidc_ssrf_transport_test.go`, `internal/users/users_suite_test.go` are all `package <pkg>_test` (black-box), no new `.golangci.yml` exclusions added. `internal/users/export_test.go`'s new exports (`ExportOIDCHTTPClient`, updated `ExportFetchJWKS`/`ExportDiscoverOIDCMeta` signatures) are consistent with the pre-existing allowlisted white-box exception for that package (nnb9.30) and don't leak the SSRF gate itself (they expose the client/functions, not a way to force the loopback branch outside the documented issuer-based selection). 6. **Doc-comment MINOR from the original review** — resolved: `validateFetchableDiscoveryURL`'s comment (oidc_settings.go:589) now correctly describes `netguard.SafeTransport()` as the default with the loopback-allowing sibling scoped to a loopback issuer, matching the actual wiring. ### Findings None. The fix closes the BLOCKER: the loopback-allowing transport is now selected only when the trusted, admin-configured issuer is itself a loopback host, and every threaded `issuer` parameter traces back to admin-configured config, never a discovery-response field. CI is green (SHA 5e75a7c2, `success`) and the PR is mergeable. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

Code RE-REVIEW of the BLOCKER fix on PR #1405 (SHA 5e75a7c2). Diff-review only, focused on commits 34a08b926 and 5e75a7c24 layered on the prior netguard extraction.

Summary of what was verified:

  • oidcClientForIssuer selection: oidcHTTPClient != defaultOIDCHTTPClient pointer-inequality correctly detects a SetOIDCHTTPClient test override and takes priority over the loopback-issuer check; SetOIDCHTTPClient's restore closure correctly resets the pointer to prev, so nesting/cleanup works. Not "always returns one client" — confirmed both branches (strict oidcHTTPClient vs oidcHTTPClientLoopback) are reachable and covered by oidc_ssrf_transport_test.go.
  • isLoopbackHost/isLoopbackIssuer: correctly matches "localhost" (case-insensitive) and IP-literal IsLoopback(); on a url.Parse error, returns false → fails closed to the strict (loopback-blocking) client. Verified http://[::1 (unterminated bracket) is a genuine url.Parse error, confirming the "unparseable issuer fails closed" test case is real, not a false negative.
  • Issuer threading traced through every call site: DiscoverOIDCMeta(issuer)oidcClientForIssuer(issuer) uses the issuer itself (correct — discovery target IS the trusted issuer); FetchJWKS(jwksURI, issuer) called from oidcVerifyAndProvision with cfg.Issuer (oidc_service.go:293/302), from fetchBackchannelJWKS with the backchannel's issuer param (oidc_backchannel.go:238), and from checkOIDCJWKS/buildOIDCChecks/TestOIDCConnectionResult with the admin-typed issuerURI under test (oidc_settings.go). fetchUserinfoClaims(..., cfg.Issuer, ...) (oidc_service.go:316) and oidcExchangeCode(..., cfg.Issuer, ...) (oidc_service.go:478) both correctly use the trusted admin-configured issuer, never the untrusted discovery-supplied endpoint URL. No call site found passing a mislabeled string (e.g. jwksURI as issuer) in production code — the one test that does (oidc_jwks_test.go:123, ExportFetchJWKS(GinkgoT(), jwksURI, jwksURI)) runs under the suite-wide BeforeEach test-mode client override (users_suite_test.go), which bypasses the loopback decision entirely, so the mismatch is inert.
  • oidcJWKSCache/newOIDCJWKSCache/Fetch/Refresh signatures consistently updated to (ctx, jwksURI, issuer); cache is keyed by jwksURI only (not issuer), but since a cache hit never triggers a new dial, this isn't a new SSRF vector — worst case is stale-but-already-legitimately-fetched bytes served under a different issuer's cache key collision, not a fresh network request steered anywhere new.
  • Conventions: no new .golangci.yml exclusions (diffed clean vs origin/main), no coverage-script exclusion additions, all new/changed test files remain package users_test/package settings_test (black-box), one-Expect-per-It maintained in the new oidc_ssrf_transport_test.go specs. export_test.go changes are signature updates to already-existing exported test hooks (ExportFetchJWKS, ExportNewOIDCJWKSCache), not new test-only exports — no nnb9 concern.
  • Doc comments (oidcHTTPClient, oidcHTTPClientLoopback, oidcClientForIssuer, checkOIDCJWKS, TestOIDCConnectionResult) are updated consistently and accurately describe the new split-client behavior; no stale references to the old single-client wiring found.

No blockers or majors found in this fix. One minor style note below.

[MINOR] internal/users/oidc_cache.go:120 — JWKS cache keyed by URI only, not (issuer, URI)
oidcJWKSCache.entries is keyed solely by jwksURI. Not a security issue today (a cache hit never re-dials, so no new SSRF surface), but it's a latent footgun: if two different admin-configured issuers ever legitimately share a jwks_uri string (unusual but not impossible for multi-tenant discovery documents), the second issuer's fetch would silently reuse the first's cached bytes without re-validating via its own oidcClientForIssuer decision. Consider keying by (issuer, jwksURI) if multi-issuer support is ever added; not worth blocking this PR over.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

Code RE-REVIEW of the BLOCKER fix on PR #1405 (SHA 5e75a7c2). Diff-review only, focused on commits 34a08b926 and 5e75a7c24 layered on the prior netguard extraction. **Summary of what was verified:** - `oidcClientForIssuer` selection: `oidcHTTPClient != defaultOIDCHTTPClient` pointer-inequality correctly detects a `SetOIDCHTTPClient` test override and takes priority over the loopback-issuer check; `SetOIDCHTTPClient`'s restore closure correctly resets the pointer to `prev`, so nesting/cleanup works. Not "always returns one client" — confirmed both branches (strict `oidcHTTPClient` vs `oidcHTTPClientLoopback`) are reachable and covered by `oidc_ssrf_transport_test.go`. - `isLoopbackHost`/`isLoopbackIssuer`: correctly matches `"localhost"` (case-insensitive) and IP-literal `IsLoopback()`; on a `url.Parse` error, returns `false` → fails closed to the strict (loopback-blocking) client. Verified `http://[::1` (unterminated bracket) is a genuine `url.Parse` error, confirming the "unparseable issuer fails closed" test case is real, not a false negative. - Issuer threading traced through every call site: `DiscoverOIDCMeta(issuer)` → `oidcClientForIssuer(issuer)` uses the issuer itself (correct — discovery target IS the trusted issuer); `FetchJWKS(jwksURI, issuer)` called from `oidcVerifyAndProvision` with `cfg.Issuer` (oidc_service.go:293/302), from `fetchBackchannelJWKS` with the backchannel's `issuer` param (oidc_backchannel.go:238), and from `checkOIDCJWKS`/`buildOIDCChecks`/`TestOIDCConnectionResult` with the admin-typed `issuerURI` under test (oidc_settings.go). `fetchUserinfoClaims(..., cfg.Issuer, ...)` (oidc_service.go:316) and `oidcExchangeCode(..., cfg.Issuer, ...)` (oidc_service.go:478) both correctly use the trusted admin-configured issuer, never the untrusted discovery-supplied endpoint URL. No call site found passing a mislabeled string (e.g. jwksURI as issuer) in production code — the one test that does (`oidc_jwks_test.go:123`, `ExportFetchJWKS(GinkgoT(), jwksURI, jwksURI)`) runs under the suite-wide `BeforeEach` test-mode client override (`users_suite_test.go`), which bypasses the loopback decision entirely, so the mismatch is inert. - `oidcJWKSCache`/`newOIDCJWKSCache`/`Fetch`/`Refresh` signatures consistently updated to `(ctx, jwksURI, issuer)`; cache is keyed by `jwksURI` only (not `issuer`), but since a cache hit never triggers a new dial, this isn't a new SSRF vector — worst case is stale-but-already-legitimately-fetched bytes served under a different issuer's cache key collision, not a fresh network request steered anywhere new. - Conventions: no new `.golangci.yml` exclusions (diffed clean vs origin/main), no coverage-script exclusion additions, all new/changed test files remain `package users_test`/`package settings_test` (black-box), one-Expect-per-It maintained in the new `oidc_ssrf_transport_test.go` specs. `export_test.go` changes are signature updates to already-existing exported test hooks (`ExportFetchJWKS`, `ExportNewOIDCJWKSCache`), not new test-only exports — no nnb9 concern. - Doc comments (oidcHTTPClient, oidcHTTPClientLoopback, oidcClientForIssuer, checkOIDCJWKS, TestOIDCConnectionResult) are updated consistently and accurately describe the new split-client behavior; no stale references to the old single-client wiring found. No blockers or majors found in this fix. One minor style note below. [MINOR] internal/users/oidc_cache.go:120 — JWKS cache keyed by URI only, not (issuer, URI) `oidcJWKSCache.entries` is keyed solely by `jwksURI`. Not a security issue today (a cache hit never re-dials, so no new SSRF surface), but it's a latent footgun: if two different admin-configured issuers ever legitimately share a `jwks_uri` string (unusual but not impossible for multi-tenant discovery documents), the second issuer's fetch would silently reuse the first's cached bytes without re-validating via its own `oidcClientForIssuer` decision. Consider keying by `(issuer, jwksURI)` if multi-issuer support is ever added; not worth blocking this PR over. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Merge branch 'main' into bd-bookshelf-qapga
All checks were successful
/ Test Race (pull_request) Successful in 2m14s
/ Coverage (pull_request) Successful in 3m8s
/ Integration (pull_request) Successful in 2m45s
/ JS Unit Tests (pull_request) Successful in 1m32s
/ E2E API (pull_request) Successful in 2m19s
/ Lint (pull_request) Successful in 4m34s
/ E2E Browser (pull_request) Successful in 4m50s
ed8c42b233
zombor merged commit 566b85914b into main 2026-08-09 14:00:24 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1405
No description provided.