fix(annotator): cap the CourtListener response body before parsing - #268
fix(annotator): cap the CourtListener response body before parsing#268williamzujkowski wants to merge 1 commit into
Conversation
Closes #223 item 3. `fetchWithRetry` called `await response.json()` with no bound, so a misbehaving or redirected endpoint could return an unbounded body and OOM the importer. Search results are tens of kilobytes; the cap is 8 MiB, far above any legitimate page and far below what threatens the runner. Deliberately not the fetcher's MAX_DOWNLOAD_BYTES (300 MiB) — that bounds bulk XML downloads and would be a cap in name only here. Two layers, because either alone is insufficient: 1. Content-Length, when it declares more than the cap, rejects before a byte of body is read. 2. A streaming read that aborts the moment the running total exceeds the cap. Content-Length is absent on a chunked response and attacker- controlled on a redirected one, so a server that lies walks straight past layer 1. Buffering via arrayBuffer() and checking afterwards would defeat the purpose: the OOM happens during the read. Mirrors the fetcher's exceedsContentLengthLimit + readBytesCapped pair. The logic is duplicated rather than shared because @civic-source/annotator does not depend on @civic-source/fetcher; hoisting both into @civic-source/shared is the DRY fix and is left as a follow-up rather than bundled into a security change. The reader optional-chains `headers` and tolerates a missing `body`. A real Response always has both, but the existing tests stub fetch with `{ ok, status, json }` and nothing else. Assuming more than is needed would turn a size guard into an availability bug — and the retry loop would have swallowed the TypeError as a transient failure, which is how this surfaced: two existing tests started taking 3s and failing. Items 1 and 2 of #223 are untouched. Item 1 (host pinning) is already fixed on main by `courtListenerSourceUrl`, which also rejects userinfo — more than the issue asked for. Item 2 wants the shared fetchWithRetry, which restructures retry semantics and deserves its own change.
|
Not merging this without your review — that was unanimous. I put the merge decision to a 7-voter panel. The option that included admin-overriding this PR's
and
That second point is fair and I'd rather it be said here than not: the first version of this reader assumed What this needs: a reviewer. The change is green (3/3 checks, matching a merged control PR in this repo), 11/11 tests, both cap layers mutation-verified independently. Nothing is blocked on me. |
Closes #223 item 3. Items 1 and 2 are addressed below but not in this diff.
The gap
fetchWithRetrycalledawait response.json()with no bound. A misbehaving or redirected endpoint could return an unbounded body and OOM the importer — a crash rather than a handled error.The fix — two layers, because either alone is insufficient
Content-Length, when it declares more than the cap, rejects before a byte of body is read. Cheap, handles the honest case.Content-Lengthis absent on a chunked response and attacker-controlled on a redirected one, so a server that lies walks straight past layer 1. Buffering witharrayBuffer()and checking afterwards would defeat the purpose — the OOM happens during the read, not after.Cap is 8 MiB. Search results are tens of kilobytes, so that is far above any legitimate page and far below what threatens the runner. Deliberately not the fetcher's
MAX_DOWNLOAD_BYTES(300 MiB) — that bounds bulk XML and would be a cap in name only here.A regression I introduced and caught
The first version called
response.headers.get(...)andresponse.body.getReader()directly. Two existing tests then began failing at ~3s each:No
headers, nobody. The TypeError was swallowed by the retry loop and re-tried with backoff — which is why it presented as a timeout rather than an error.A real
Responsealways has both, so the stub is unfaithful. But the right fix is still to tolerate it: assuming more of the object than the guard needs turns a size guard into an availability bug, and the retry loop hides the cause. The reader now optional-chainsheadersand treats a missingbodyas "no stream to bound", deferring to the object's own parse. No test was changed to accommodate the production code.Verification
client.test.tspass ·tsc --noEmit0 errors ·eslintcleanContent-Lengthpre-checkrejects up-front when Content-Length declares more than the capfailsrejects a body that exceeds the cap while streaming, despite an honest-looking Content-LengthfailsFour new tests, including the benign case (an ordinary search page still parses) and malformed JSON returning an error
Resultrather than throwing past the retry loop.On the other two items
main.courtListenerSourceUrlresolves vianew URL(absoluteUrl, base), pins the origin, and additionally rejects userinfo (https://evil@www.courtlistener.com/…) — which the issue did not ask for. Worth ticking off in security/robustness: harden annotator CourtListener client (host-pinning, shared retry, response cap) #223.fetchWithRetry) is still open. The shared version returnsResult<Response>, so adopting it means restructuring the annotator's JSON parse and 401 mapping — a real change to retry semantics in security-sensitive code, and it deserves its own PR rather than riding along here.Known duplication
readJsonCappedreimplements the fetcher'sexceedsContentLengthLimit+readBytesCappedpair because@civic-source/annotatordoes not depend on@civic-source/fetcher. Hoisting both into@civic-source/sharedis the DRY fix; I left it out rather than bundle a cross-package refactor into a security change. Happy to do it as a follow-up if you want it.