test(k8s): pin Kubernetes workload identity against a real cluster - #778
Conversation
|
Pushed a correction to this PR. The tests I opened it with were wrong, and passed only because CI runs kind. The defect
EKS, GKE and AKS publish a public https issuer with public OIDC discovery by default. Probed against the real guard: So on the major managed distributions this feature works today with no configuration beyond the trusted issuer — and my assertions would fail there, demanding a refusal that correctly did not happen. The fixBoth tests now derive their expectation from what the cluster actually publishes, resolving the address independently of the code under test:
The suite is now truthful on whatever cluster it is pointed at, and states the rule rather than one cluster's instance of it. Still passes on kind (both addresses private, both branches take the refusal path). Docs corrected tooREADME said "requires a publicly-routable issuer/JWKS endpoint", which reads as "this does not work" when for EKS/GKE/AKS it is the default. It now names which clusters work untouched, and gives the one-line fix for the rest: point
Verification |
The Kubernetes workload-identity path had no test against a real cluster. Every existing test substitutes an httptest server, which replaces the single property that decides whether the feature works: the ADDRESS the cluster publishes. Measured on kind: issuer https://kubernetes.default.svc.cluster.local (ClusterIP) jwks_uri https://172.27.0.2:6443/openid/v1/jwks (RFC 1918) apiserver https://127.0.0.1:60438 (loopback) validators.SafeHTTPClient refuses every one of those unconditionally, and clientauth has no AllowPrivate escape hatch. So an in-cluster deployment cannot fetch the cluster's JWKS OR reach TokenReview — while performTokenReview reads Authorizer's own in-cluster ServiceAccount token to authenticate to an apiserver it can never dial. The two assumptions contradict each other. performTokenReview documented half of this for TokenReview. The JWKS half was undocumented, and applies with or without TokenReview: static_jwks_url and oidc_discovery are refused for the same reason. The operator-visible symptom is a bare 400 invalid_client / "Client authentication failed" with nothing pointing at the refused fetch. These tests assert current behaviour, not desired behaviour. They are a pin: if the SSRF policy changes, they say exactly which Kubernetes behaviour changed with it, and the end-to-end case names itself as the assertion to invert once the address problem is solved. - scripts/k8s-e2e.sh: kind (default) or k3d via K8S_RUNTIME, always tears down, K8S_KEEP=1 to debug. - make test-k8s - .github/workflows/k8s.yml: scheduled + on PRs touching the code that decides cluster reachability. Not per-push — it provisions a cluster to guard a limitation that changes rarely. - Behind the `k8s` build tag, so `make test` is unaffected (verified: 43 packages, 0 FAIL, file not compiled in). - README no longer lists Kubernetes TokenReview without the caveat.
The first version of this suite asserted that a cluster's issuer, jwks_uri and apiserver are ALWAYS private, and that a projected token therefore cannot authenticate. That is a false generalisation from kind. EKS, GKE and AKS publish a PUBLIC https issuer with public OIDC discovery by default: https://oidc.eks.<region>.amazonaws.com/id/<id> https://container.googleapis.com/v1/projects/<p>/locations/<l>/clusters/<c> Both pass SafeHTTPClient unmodified — verified by probe. So on the major managed distributions the feature works today with no configuration beyond the trusted issuer, and the old assertions would FAIL there, claiming a refusal that correctly did not happen. They passed only because CI runs kind, which uses the default --service-account-issuer. The tests now derive their expectation from what the cluster actually publishes, resolving the address independently of the code under test: private address -> MUST be refused, workload cannot authenticate public address -> MUST be accepted, workload MUST authenticate so the suite is truthful on whatever cluster it is pointed at, and states the real rule rather than one cluster's instance of it. README and performTokenReview's doc comment carried the same overstatement — "requires a publicly-routable issuer/JWKS endpoint" reads as "this does not work", when for EKS/GKE/AKS it is the default. Both now name which clusters work untouched and give the one-line fix for the rest: point jwks_url at a reachable mirror, since issuer_url is only matched against `iss` and is never fetched.
…ks_url The JWKS-mirror setup is what the docs tell operators of default-issuer clusters (kubeadm, kind) to use: register issuer_url as the cluster's own unroutable issuer and point jwks_url at a reachable mirror. It works because issuer_url is a MATCHING KEY, never an address — looked up via GetTrustedIssuerByIssuerURL and compared to the assertion's iss, with the only fetch of it living in the oidc_discovery branch. Nothing in the type system says so, and a future change that resolved it "for validation" would break every one of those deployments silently. Asserts on the URLs actually fetched, not just on the resolved client: the fetch seam returns the same JWKS whatever it is handed, so an outcome-only assertion would still pass after such a regression.
efed548 to
675c6e9
Compare
* 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.
Asked to make sure nothing breaks in Kubernetes and to add k3d/kind tests. The
tests found something bigger than the change that prompted them.
The finding
In-cluster Kubernetes workload identity cannot work. Not TokenReview
specifically — the whole path.
Measured against a real kind cluster:
SafeHTTPClienthttps://kubernetes.default.svc.cluster.localjwks_urihttps://172.27.0.2:6443/openid/v1/jwkshttps://127.0.0.1:61411validators.SafeHTTPClientrejects private, loopback and link-local addressesunconditionally, and
clientauthhas noSafeHTTPClientAllowPrivateescapehatch (the two that exist are gated on
--env=e2eand belong to oauth_sso andwebhook delivery).
Meanwhile
performTokenReviewreads Authorizer's own in-clusterServiceAccount token (
/var/run/secrets/kubernetes.io/serviceaccount/token) toauthenticate to an apiserver it can never dial. The two assumptions contradict
each other.
performTokenReviewalready documented half of this. The JWKS half wasundocumented and is the wider problem: it applies with or without TokenReview,
because
static_jwks_urlandoidc_discoveryboth point at the cluster's ownprivate address. That is why this is not a kind artefact —
kubernetes.default.svcis always a ClusterIP, and
jwks_urialways points at the apiserver.The operator-visible symptom, captured by the test:
Nothing points at the refused fetch. Worth fixing whenever the address problem is.
Why these tests, and why they assert failure
They assert current behaviour, deliberately. They are a pin, not an
aspiration: if someone changes the SSRF policy, the suite says precisely which
Kubernetes behaviour they changed.
TestK8sWorkloadAuthenticationEndToEndnamesitself as the assertion to invert once this is fixed.
Three cases, and the middle one earns its place:
TestK8sProjectedTokenIsWellFormedproves the token side is correct — real
iss,sub, audience-boundaud— sothe end-to-end failure is attributable to the fetch address and nothing else.
Without it a reader could reasonably blame the token.
Mechanics
scripts/k8s-e2e.sh— kind by default,K8S_RUNTIME=k3dfor k3d,K8S_KEEP=1to leave the cluster up. Always tears down, including on failure.
make test-k8s.github/workflows/k8s.yml— scheduled weekly plus PRs touching the code thatdecides cluster reachability. Not per-push: it provisions a cluster to guard a
limitation that changes rarely.
k8sbuild tag. Verifiedmake testis unaffected — 43 packages,0 FAIL, and the file is not compiled into normal runs.
Verification
Cold run from no cluster: create → test → teardown, exit 0, no cluster left behind.
Not fixed here
Making in-cluster workload identity actually work needs a security decision, not
a patch: a scoped SSRF exemption for an operator-declared apiserver/JWKS host
with CA pinning, or an explicit in-cluster transport.
SafeHTTPClientAllowPrivateasks for careful review before a third caller, and this would be one. README now
states the constraint instead of listing the feature unqualified.