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>
94 lines
3.6 KiB
Markdown
94 lines
3.6 KiB
Markdown
# 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`):
|
|
|
|
```python
|
|
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 `RuntimeError`s) 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)_
|