Skip to content

security(validators): block the two reachable special-purpose ranges - #781

Merged
lakhansamani merged 1 commit into
mainfrom
security/ssrf-reserved-ranges
Aug 17, 2026
Merged

security(validators): block the two reachable special-purpose ranges#781
lakhansamani merged 1 commit into
mainfrom
security/ssrf-reserved-ranges

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

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:

Range Purpose Before
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 TEST-NET 1–3 blocked
198.18.0.0/15 RFC 2544 benchmarking reachable
192.88.99.0/24 6to4 relay anycast (deprecated, RFC 7526) reachable

Blocking three of the five and not the other two reads as an oversight rather than a decision.

Why 198.18.0.0/15 is the one that matters

Not 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/24 carries 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/24 Docker network, fetched through the unmodified SafeHTTPClient — real resolve-and-pin dialer, no seam:

REAL FETCH THROUGH THE GUARD: 200 body={"keys":[]}

Same trick scales to a whole cluster: a kind cluster on a 198.19.0.0/16 Docker network publishes jwks_uri = https://198.19.0.2:6443/openid/v1/jwks and an apiserver ClusterIP of 198.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=e2e route 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.

go build ./...   OK
go vet ./...     OK
make test        exit 0 — 43 packages, 0 FAIL
make lint        exit 0

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
lakhansamani merged commit 0307cba into main Aug 17, 2026
4 checks passed
@lakhansamani
lakhansamani deleted the security/ssrf-reserved-ranges branch August 17, 2026 06:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant