daybook/issues/015-deeply-nested-json-recursionerror-500.md
peter 6d2fe2147c Archive resolved issues; add open issues #014-#016
Moves the 11 Fixed issues (#001-006, #008-012) into issues/_archive_/,
which is gitignored — they stay on local disk and in git history but drop
out of the committed tree, keeping the issues/ dir focused on what's open.
The sibling test project reads this dir by local path, so it still sees
the archived files. Adds the three new open issues found in round-3
testing: #014 (stored XSS in markdown/deck/html_fragment — High), #015
(RecursionError -> 500 on deeply nested JSON), #016 (ragged CSV rows lose
data in the rendered table). Documents the archive convention in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:27:45 +01:00

3.6 KiB

015 — Deeply nested JSON raises RecursionError → unhandled HTTP 500 (two code paths)

Status: Open Severity: Medium — unhandled 500 where a 400 is correct; small (~30 KB) request crashes the handling worker (minor DoS shape); full traceback exposed when DEBUG=True.

Round-3 probe of the json kind ("deeply nested / very large" from the #013 handover).

Description

Two distinct code paths turn deeply-nested JSON into an uncaught RecursionError and a 500. The root cause is shared: Python's RecursionError is a subclass of RuntimeError, not ValueError, so the usual except (TypeError, ValueError) guards around JSON parsing don't catch it.

Path 1 — json kind content validation

validate_content_shape (artifacts/views.py:987):

if kind == Artifact.JSON:
    try:
        json.loads(content)
    except (TypeError, ValueError) as exc:                     # <-- misses RecursionError
        return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)

Posting a json artifact whose content is "[" * 5000 + "]" * 5000 (valid, just deep) raises RecursionError: maximum recursion depth exceeded while decoding a JSON array from a unicode string, which escapes the except and becomes a 500.

Reproduced:

POST /api/artifacts  kind=json  content="[[[...5000...]]]"
-> 500  (RecursionError at views.py:987, via post() at views.py:133)

Path 2 — DRF request-body parser (affects ALL kinds)

The same class of payload in the request body itself (e.g. a deeply nested metadata object, or nesting anywhere in the JSON body) crashes in DRF's JSONParser before the view runs, so it isn't json-kind specific:

POST /api/artifacts  kind=markdown  metadata={"n":{"n":{...5000...}}}
(body ~30 KB, built as a raw string so it's well-formed)
-> 500  (RecursionError inside DRF JSONParser)

Control: same shape at depth 100 -> 201  (confirms it's depth, not size)

Not a size issue

A 50,000-key flat object (~978 KB) posts fine (201). It's specifically nesting depth that triggers it, so a byte-size limit wouldn't address this.

Impact

  • Wrong status: an unhandled 500 where malformed/pathological input should be a clean 400.
  • Minor DoS shape: a single ~30 KB request reliably errors out the worker handling it. Not a resource-exhaustion amplifier, but a cheap way to generate server-side 500s.
  • Info exposure only on a DEBUG=True instance (like the local dev one this was found on): the full Django traceback page is returned. Prod should have DEBUG=False, so there this degrades to a generic 500 — the status-code bug remains either way. (Flagging the traceback exposure only as a reminder to confirm prod DEBUG is off, not as the core issue.)

Suggested Fix

  • Path 1 (cheap, local): add RecursionError to the caught exceptions at views.py:988 (it's a RuntimeError, so either list it explicitly or catch (TypeError, ValueError, RecursionError)), returning the same 400.
  • Path 2 (needs a global guard): DRF's default exception handler doesn't map RecursionError to a 4xx. Options: a custom REST_FRAMEWORK["EXCEPTION_HANDLER"] that converts RecursionError (and other parse-time RuntimeErrors) to 400; and/or a bounded JSON parser / max-nesting-depth check applied to request bodies. A depth cap (e.g. reject bodies nested beyond N levels) is the most robust since it rejects before the recursive descent exhausts the stack.
  • Consider a single shared "parse JSON safely" helper used by both the content validator and any metadata/tag_colors parsing, so the guard lives in one place.

Resolution

(pending)