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>
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=Trueinstance (like the local dev one this was found on): the full Django traceback page is returned. Prod should haveDEBUG=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 prodDEBUGis off, not as the core issue.)
Suggested Fix
- Path 1 (cheap, local): add
RecursionErrorto the caught exceptions atviews.py:988(it's aRuntimeError, 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
RecursionErrorto a 4xx. Options: a customREST_FRAMEWORK["EXCEPTION_HANDLER"]that convertsRecursionError(and other parse-timeRuntimeErrors) to400; 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)