security(validators): block the two reachable special-purpose ranges - #781
Merged
Conversation
The SSRF block list covered all three TEST-NET ranges but not 198.18.0.0/15 or 192.88.99.0/24 — from the same IANA special-purpose registry, equally non-routable. They were the only members of it still reachable, which reads as an oversight rather than a decision. 198.18.0.0/15 is the one with real exposure. Not being globally routable is exactly why organisations use it internally — as a lab range, or to number a network they do not want colliding with RFC 1918 — so an SSRF there reaches a live internal service through an address that reads as public to every "is this private?" intuition, including this one. 192.88.99.0/24 (6to4 relay anycast) is deprecated by RFC 7526 and carries far less risk; blocked for completeness so the registry is covered as a set rather than range by range. Found while looking for a way to test the JWKS-fetch path end to end: a container on a 198.18.0.0/24 docker network was fetched successfully through the UNMODIFIED guard, which is a good test mechanism and a bad property of a security control. The new test walks the ranges as a table so the block list is asserted as a set. Verified failing before the fix on exactly the three added rows (198.18.0.5, 198.19.255.5, 192.88.99.5) and passing after. No deployment can depend on this: nothing in the repo, the e2e stack or the compose networks uses either range, and both are unroutable on the public internet by definition.
lakhansamani
added a commit
that referenced
this pull request
Aug 17, 2026
The RFC 7523 client_assertion path had no test where Authorizer actually FETCHES the signing keys. Every in-package test substitutes the fetch seam, which removes the one thing that decides whether the feature works in production: the address. mock-oauth stands in for the cluster — it already generates an RS256 keypair, serves /jwks and has discovery, so it only needed an endpoint minting a JWT shaped like a projected ServiceAccount token. Four cases: a workload authenticates, an assertion is single-use, an unlisted subject is refused, and a wrong-audience assertion is refused. Reaching a compose-network mirror needs the private-address escape hatch, so clientauth becomes the third caller of SafeHTTPClientAllowPrivate, gated on Config.Env == E2EEnv exactly as the SSO broker and webhook delivery are. That function's doc asks for careful review before a third caller; newSafeClient's comment carries the reasoning, including why the alternatives are worse — relaxing the guard's ranges weakens a production control for a test, and hosting the mirror on a range the guard happens to permit couples the suite to a block-list gap (there were two; #781 closes them) and loses the coverage silently once that gap is fixed. The gate is asserted by OUTCOME rather than by reading the flag: a private address must be refused under production, under an empty env, and with a nil Config, and must be genuinely reachable under e2e. An inverted condition or a drifting default fails there. Also corrects a false claim found while building this. The replay comment said K8s SA tokens carry no jti, and the (iss,sub,iat,exp) fallback was justified by that. Verified against a live cluster: a projected token's claims are aud, exp, iat, iss, jti, kubernetes.io, nbf, sub — it does carry one. The fallback is still correct to keep, for issuers that omit jti as RFC 7523 permits, but not for the stated reason. The mock mints a jti for the same reason: real tokens have one, and without it two mints in the same second collide on the fallback key.
lakhansamani
added a commit
that referenced
this pull request
Aug 17, 2026
The RFC 7523 client_assertion path had no test where Authorizer actually FETCHES the signing keys. Every in-package test substitutes the fetch seam, which removes the one thing that decides whether the feature works in production: the address. mock-oauth stands in for the cluster — it already generates an RS256 keypair, serves /jwks and has discovery, so it only needed an endpoint minting a JWT shaped like a projected ServiceAccount token. Four cases: a workload authenticates, an assertion is single-use, an unlisted subject is refused, and a wrong-audience assertion is refused. Reaching a compose-network mirror needs the private-address escape hatch, so clientauth becomes the third caller of SafeHTTPClientAllowPrivate, gated on Config.Env == E2EEnv exactly as the SSO broker and webhook delivery are. That function's doc asks for careful review before a third caller; newSafeClient's comment carries the reasoning, including why the alternatives are worse — relaxing the guard's ranges weakens a production control for a test, and hosting the mirror on a range the guard happens to permit couples the suite to a block-list gap (there were two; #781 closes them) and loses the coverage silently once that gap is fixed. The gate is asserted by OUTCOME rather than by reading the flag: a private address must be refused under production, under an empty env, and with a nil Config, and must be genuinely reachable under e2e. An inverted condition or a drifting default fails there. Also corrects a false claim found while building this. The replay comment said K8s SA tokens carry no jti, and the (iss,sub,iat,exp) fallback was justified by that. Verified against a live cluster: a projected token's claims are aud, exp, iat, iss, jti, kubernetes.io, nbf, sub — it does carry one. The fallback is still correct to keep, for issuers that omit jti as RFC 7523 permits, but not for the stated reason. The mock mints a jti for the same reason: real tokens have one, and without it two mints in the same second collide on the fallback key.
lakhansamani
added a commit
that referenced
this pull request
Aug 19, 2026
* docs(changelog): cover #773-#783 Unreleased linked 50 PRs and none of #773-#783, so every change made after rc.22 - including four security fixes - was missing from the CHANGELOG a user reads at 2.4.0. Refs #773, #774, #775, #776, #777, #778, #779, #781, #782, #783 * chore: bump web/app to authorizer-react 2.2.0 authorizer-react 2.2.0 is published on authorizer-js 4.0.0; drop the -rc.7 pin. Also stamps the CHANGELOG's Unreleased section as 2.4.0. * test(e2e): make the authorizer host ports overridable The seven authorizer services published fixed host ports, so the suite could not run on a machine already using 8080-8086 - it failed at "address already in use" before any test ran. The mock services already take this shape. Playwright reaches every service by compose DNS, so the host mapping is for humans only and the defaults are unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while looking for a way to test the JWKS-fetch path end to end. The mechanism I found works because of a gap in the SSRF block list.
The gap
The list covers all three TEST-NET ranges but not two others from the same IANA special-purpose registry:
192.0.2.0/24,198.51.100.0/24,203.0.113.0/24198.18.0.0/15192.88.99.0/24Blocking three of the five and not the other two reads as an oversight rather than a decision.
Why
198.18.0.0/15is the one that mattersNot being globally routable is exactly why organisations use it internally — as a lab range, or to number a network they don't want colliding with RFC 1918. So an SSRF there reaches a live internal service, through an address that reads as public to every "is this private?" intuition, including this function's.
192.88.99.0/24carries far less risk (deprecated anycast relay); it's included so the registry is covered as a set rather than range by range.How it surfaced
Demonstrated, not theorised. A container on a
198.18.0.0/24Docker network, fetched through the unmodifiedSafeHTTPClient— real resolve-and-pin dialer, no seam:Same trick scales to a whole cluster: a kind cluster on a
198.19.0.0/16Docker network publishesjwks_uri = https://198.19.0.2:6443/openid/v1/jwksand an apiserver ClusterIP of198.19.128.1, both of which the guard accepted. That's a useful test mechanism and a bad property of a security control — so the control gets fixed, and the e2e coverage takes the reviewed--env=e2eroute instead (separate PR).Test
The new test walks the ranges as a table, so the block list is asserted as a set. The previous tests picked one representative range each, which is how two omissions survived — an absence of evidence rather than evidence of absence.
Verified failing before the fix on exactly the three added rows (
198.18.0.5,198.19.255.5,192.88.99.5), passing after.Compatibility
Nothing can depend on this. No code, test, compose file or CI config in the repo references either range, and both are unroutable on the public internet by definition — there is no legitimate outbound target in them.