OIDC Test Connection: structured per-check diagnostic [shot:oidc-test-connection-structured-diagnostic] (bookshelf-tm38.15) #1396
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-tm38.15"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
scopes_supported, PKCE (S256), end-session endpoint (single logout), and back-channel logout support.warn/skipnever flip overall success — only a hardfail(discovery unreachable, missing required endpoints, or JWKS unreachable/empty) does, matching the "yellow = optional capability missing but OIDC still works" framing.redirect_uri— the one thing this diagnostic cannot verify itself (IdP-side registration), and the most common cause of a login failure even when every check passes..email-settings-rowlist shape and.badgepill styling (no bespoke classes, no inlinestyle=).docs/content/docs/administering/oidc.mdwith a new "Test Connection diagnostic" section explaining each check and the redirect-URI hint.Test plan
make test— full unit suite greenmake coverage— 100% gate green (Docker + MySQL testcontainers)make lint— 0 issues, all policy checks greennpx vitest run --coverage— full JS suite green, 100% coverage including new controller testsgo build -tags e2e ./e2e/...— compiles; extended the existing OIDC journeyIt(journey_settings_test.go) to assert the structured diagnostic renders a failed "Discovery document" row against an unreachable issuer, and to capture a screenshot ([shot:...]marker in the PR title triggers CI auto-post)Closes bead bookshelf-tm38.15 on merge.
[BLOCKER] internal/settings/oidc_settings.go:611-628 (checkOIDCJWKS) / internal/settings/wire.go:281 — jwks_uri from the discovery document is fetched with zero SSRF validation
TestOIDCConnectionResultvalidates the admin-suppliedissuer_uriviavalidateIssuerURI/isRestrictedHost(blocks private/loopback/link-local/AWS-metadata literal IPs) before callingdiscoverMeta. But the discovery response itself is attacker-influenced content:meta.JWKSURIcomes straight from the JSON the issuer host returns, and this PR wires a brand-new server-side fetch of that URL (users.FetchJWKS, viacheckOIDCJWKS) with no host/scheme validation at all — notisRestrictedHost, not even a scheme check. An admin (or anyone who can get an admin to run Test Connection against an attacker-controlled/compromised issuer — this is not the saved/trusted config, it's whatever is currently typed into the issuer field peroidc_settings_controller.js'stestConnection()) can serve a discovery document with"jwks_uri": "http://169.254.169.254/latest/meta-data/iam/security-credentials/..."or"http://127.0.0.1:9000/..."or any RFC1918 address, and pergamum's server will make that request. This is the exact SSRF classisRestrictedHost/validateIssuerURIwas written to prevent for the issuer field (see the "admin-only SSRF via the test-connection handler" comments at oidc_settings.go:481,511) — but the protection was never extended to the derivedjwks_uri, so it's trivially bypassed via a second hop. The response isn't fully blind either:checkOIDCJWKSreturns distinguishable outcomes ("could not fetch signing keys" / "JWKS response contains no usable keys" / "N signing key(s) available") that give an oracle for probing reachability/behavior of internal hosts and ports from outside.Fix: run
meta.JWKSURI(and ideallyAuthorizationEndpoint/TokenEndpoint/UserinfoEndpoint/EndSessionEndpoint, all of which are also unvalidated attacker-influenced discovery fields, even though only JWKS is fetched today) through the samevalidateIssuerURI/isRestrictedHostcheck before callingfetchJWKS, and fail the check with a generic "invalid signing-key endpoint" detail (no raw URL) rather than attempting the fetch.[MINOR] internal/users/oidc_jwks.go:38-96 (DiscoverOIDCMeta / FetchJWKS, pre-existing/unchanged) — shared
oidcHTTPClienthas noCheckRedirect, so a validated-at-request-time https issuer can still redirect the discovery/JWKS fetch to an internal target via a 3xx response, bypassing the literal-IP check entirely (redirect target isn't re-validated). Not introduced by this PR, but this PR is what makes the JWKS leg of that client reachable from an unauthenticated-of-full-OAuth-flow admin action (Test Connection) instead of only from a completed login callback. Worth cappingCheckRedirect(deny or re-validate each hop) while addressing the BLOCKER above, same call site.[MINOR] internal/settings/oidc_settings.go:508-513 (isRestrictedHost, pre-existing) — the doc comment already flags that literal-IP-only checking doesn't catch DNS rebinding (hostname resolves to a private IP at request time, not URL-parse time). Not a regression from this PR, but since the fix for the BLOCKER above will likely reuse
isRestrictedHostforjwks_uri, worth tracking as a follow-up: resolve-then-check (or use a customDialContextthat rejects private-IP connections at dial time) for full protection instead of syntactic-IP filtering.REVIEW VERDICT: 1 blocker, 0 major, 2 minor
Security Re-Review — PR #1396 (bookshelf-tm38.15)
Re-reviewed the fix for the prior BLOCKER (unvalidated discovery-derived
jwks_uriSSRF).BLOCKER verification
Closed.
checkOIDCJWKS(internal/settings/oidc_settings.go:215-235) callsvalidateFetchableDiscoveryURL(jwksURI)(oidc_settings.go:531) before invokingfetchJWKS.validateFetchableDiscoveryURLrequireshttpsscheme and rejects any literal-IP host viaisRestrictedHost(private/loopback/link-local/multicast/unspecified + the 169.254.169.254 metadata address) — the exact literal-IP PoC (127.0.0.1, 169.254.169.254, 10.x) is rejected pre-fetch, confirmed by theDescribeTableregression test at oidc_test_connection_test.go:531-553, which also assertsfetchJWKSCalledstaysfalsefor each malicious entry."could not fetch signing keys: jwks_uri is not an allowed address"— no URL/host/IP is echoed (oidc_settings.go:222), verified by the “does not leak the rejected URI or host” test (oidc_test_connection_test.go:555-568).oidcHTTPClient.CheckRedirectnow unconditionally rejects every redirect (internal/users/oidc_jwks.go:813-818), with a regression test proving a JWKS redirect is not followed (oidc_jwks_test.go:841-860). None of discovery/JWKS/token/userinfo legitimately need a redirect, so this has no functional downside.checkOIDCEndSession,checkOIDCBackchannelLogout,checkOIDCEndpoints's userinfo check) only presence-check/echo the field back — they never issue a server-side fetch, so they don't need the same guard. Confirmed by readingbuildOIDCChecks(oidc_settings.go:159-173) and each check function's body.isRestrictedHostonly inspectsurl.Hostname()when it parses as a literal IP (net.ParseIP); a hostname that only resolves to a private/loopback IP at dial time is not caught here. That is a narrower, harder-to-exploit residual (attacker needs DNS control + the dial-time race), correctly separated from the demonstrated PoC (literal-IP jwks_uri), which this fix fully closes.Other observations (non-blocking)
[MINOR] internal/users/oidc_jwks.go — the production OIDC login path (
newOIDCJWKSCache→FetchJWKS, wired in internal/users/wire.go:182) fetchesjwks_urifrom the discovery document of the admin-saved,validateIssuerURI-checked issuer, but does not itself re-validatejwks_uribefore fetch (only the Test Connection diagnostic path gained that guard in this PR). This is a narrower trust model — the issuer itself is trusted admin config and the RFC 8414 issuer-match check inDiscoverOIDCMetalimits document substitution — and out of scope for this PR's stated fix, but worth a follow-up bead to applyvalidateFetchableDiscoveryURL(or equivalent) symmetrically to the login-time JWKS fetch for defense in depth, since a compromised/malicious IdP could still steer that fetch.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code re-review (post SSRF-fix) — PR #1396 / bookshelf-tm38.15 @
70ea049bFocused on the delta since the prior review:
validateFetchableDiscoveryURL, theCheckRedirecton the sharedoidcHTTPClient, and the dead-branch removal.[MAJOR] internal/settings/oidc_settings.go:517-519 — doc comment overstates a security control that does not exist yet
The doc comment on
validateFetchableDiscoveryURLstates: "the real SSRF backstop for hostname-based and redirect-based attacks is the dial-time safe transport on the shared OIDC HTTP client." This is not true as of this commit:internal/users/oidc_jwks.go'soidcHTTPClient(checked at70ea049b) is a plain&http.Client{Timeout: ..., CheckRedirect: ...}with no customTransport/DialContext— there is no dial-time IP validation analogous tointernal/cover'ssafeTransport/safeDialContext. That gap is exactly what the deferred follow-upbookshelf-qapga(correctly filed, correctly scoped) exists to close. A comment asserting the backstop already exists is a real risk in a security-sensitive function: a future reviewer or engineer skimming this comment could reasonably conclude hostname-based/DNS-rebinding SSRF is already mitigated and deprioritizeqapga, when in fact only the redirect-bypass and literal-IP cases are closed today. Fix: reword to something like "hostname-based and DNS-rebinding SSRF is NOT yet covered by this check or by oidcHTTPClient (tracked in bookshelf-qapga); only redirect-following (CheckRedirect) and literal-IP scheme/host validation are enforced today."Everything else checked out:
checkOIDCJWKS(oidc_settings.go:335) callsvalidateFetchableDiscoveryURLand returns on error BEFOREfetchJWKSis ever invoked (line 338) — correct ordering, confirmed by the DescribeTable assertingfetchJWKSCalledstays false for loopback/metadata/RFC1918/http/malformed jwks_uri.CheckRedirecton the sharedoidcHTTPClient(internal/users/oidc_jwks.go:51-56) is safe for the real login flow: it's used for discovery (GET), JWKS (GET), token exchange (POST viaoauth2.HTTPClientcontext value), and userinfo (GET) — none of pergamum's legitimate calls require following a redirect, and the app's own browser-facing 302s (login → /authorize, callback → app pages) go throughhttp.ResponseWriter, not this client, so they're unaffected. New regression test in oidc_jwks_test.go exercises a redirecting JWKS server and asserts the redirect is not followed.raw == ""branch invalidateFetchableDiscoveryURL(commit70ea049b) is genuinely unreachable via the public path —TestOIDCConnectionResult's completeness gate (meta.JWKSURI == "") rejects a blank jwks_uri beforecheckOIDCJWKSis ever called — verified against oidc_settings.go'sbuildOIDCChecks/TestOIDCConnectionResultcall order. Not a coverage-gaming deletion.package settings_test/package users_test), assert behavior (not-called + no-leak) rather than just a failure result, and no.golangci.ymlorscripts/check-coverage.shexclusions were added in this diff.bookshelf-qapgais scoped honestly and correctly identifies the remaining gap (DNS-rebinding / hostname-based SSRF) — it is not being used to paper over an open BLOCKER; the redirect-bypass and literal-IP SSRF paths that were the prior review's actual BLOCKER are closed in this diff.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
4a9e78d07b36aa12a00dzombor referenced this pull request2026-08-08 16:29:53 +00:00