Found alongside the YAML-escaping fix (separate PR). Three lower-severity hardening gaps in @civic-source/annotator's CourtListener client, each adjacent to an already-shipped fix in another package but not present here.
1. HttpUrlSchema does not pin host — absolute_url can redirect off-domain (MEDIUM)
annotator.ts:131 builds sourceUrl = \https://www.courtlistener.com${result.absolute_url}\`` from untrusted API data. absolute_url = '@evil.com/x' → https://[email protected]/x (authority evil.com); .evil.com/x → ...courtlistener.com.evil.com/x. Both pass HttpUrlSchema (#210 only blocks non-http(s) schemes, not host). These land in sourceUrl and are later rendered as hrefs, so a poisoned field becomes an off-site link in published annotations.
Fix: parse with new URL(absolute_url, base) and assert hostname === 'www.courtlistener.com', or require absolute_url to match /^\/[\w/-]*$/. (Mirrors the #201/#205 fetcher host-pinning.)
2. Client reimplements retry, bypassing shared hardening; no abort/timeout (MEDIUM)
client.ts:88-132 defines a private fetchWithRetry instead of using @civic-source/shared's, so it does NOT inherit #212/#213: it ignores Retry-After on 429 (fixed backoff only), and fetch is called with no signal/timeout, so a hung connection blocks searchByStatute indefinitely.
Fix: use the shared fetchWithRetry(url, { headers, signal, logger }); keep only the annotator-specific 401 mapping.
3. No response size cap on API body (LOW)
client.ts:97 calls await response.json() with no Content-Length check or streaming cap; a misbehaving/redirected endpoint can return an unbounded body and OOM the importer. (Same class as the fetcher's #201/#205 cap.)
Fix: check content-length against a few-MB max before .json(), or read with a capped reader.
Notes
Found alongside the YAML-escaping fix (separate PR). Three lower-severity hardening gaps in
@civic-source/annotator's CourtListener client, each adjacent to an already-shipped fix in another package but not present here.1.
HttpUrlSchemadoes not pin host —absolute_urlcan redirect off-domain (MEDIUM)annotator.ts:131buildssourceUrl = \https://www.courtlistener.com${result.absolute_url}\`` from untrusted API data.absolute_url = '@evil.com/x'→https://[email protected]/x(authorityevil.com);.evil.com/x→...courtlistener.com.evil.com/x. Both passHttpUrlSchema(#210 only blocks non-http(s) schemes, not host). These land insourceUrland are later rendered ashrefs, so a poisoned field becomes an off-site link in published annotations.Fix: parse with
new URL(absolute_url, base)and asserthostname === 'www.courtlistener.com', or requireabsolute_urlto match/^\/[\w/-]*$/. (Mirrors the #201/#205 fetcher host-pinning.)2. Client reimplements retry, bypassing shared hardening; no abort/timeout (MEDIUM)
client.ts:88-132defines a privatefetchWithRetryinstead of using@civic-source/shared's, so it does NOT inherit #212/#213: it ignoresRetry-Afteron 429 (fixed backoff only), andfetchis called with nosignal/timeout, so a hung connection blockssearchByStatuteindefinitely.Fix: use the shared
fetchWithRetry(url, { headers, signal, logger }); keep only the annotator-specific 401 mapping.3. No response size cap on API body (LOW)
client.ts:97callsawait response.json()with no Content-Length check or streaming cap; a misbehaving/redirected endpoint can return an unbounded body and OOM the importer. (Same class as the fetcher's #201/#205 cap.)Fix: check
content-lengthagainst a few-MB max before.json(), or read with a capped reader.Notes
fix/annotator-yaml-escaping).