Anonymous directory pages each run an unpaged query over the whole catalogue on a shared 10-connection pool #341
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the security-audit skill (run-1, 2026-10-20). Source-read only: nothing was run, so this is a lead that still needs its local check, not a confirmed finding. Two independent verifiers did not disprove it.
Anyone on the internet can request /, /all, /search?q=... or /category/{name} on ipx-directory with no token (#215). Each request calls Store::list: one JOIN of feeds with snapshots, grouped and sorted, over every listed feed, materialised as a Vec
, then filtered in Rust. /all and /search render a tile for every match with no page size, so the response is also catalogue-sized. Apple's charts alone add about a thousand shows (apple.rs:22 comment), plus every instance's snapshot. A unique query string (/search?q=, or /all?x=, since Filter ignores unknown keys) defeats the only control, Cache-Control public, max-age=300, at any shared cache, and the service has no rate, concurrency or timeout layer. A flood of small GETs therefore becomes whole-catalogue database and CPU work queued on the fixed 10-connection Postgres pool that the refresher, snapshot writes and authenticated /v1 calls from ipx servers also use, on the Postgres server that CLAUDE.md says also holds production ipx's database. The amplification is linear in catalogue size, not superlinear, and recovers when the requests stop; the same query is already served to token holders by GET /v1/feeds, so the new element is only that anonymous callers reach it. Whether it matters depends on Cloudflare caching or rate limiting in front of directory.ipxreader.app, which is not in the repository.Claimed root cause. The public pages call the unpaged Store::list (acknowledged by the store.rs:309 ponytail comment) on every request and filter in memory, with no result cache, page size, or per-client/concurrency limit in front of the shared database pool, so anonymous request volume converts directly into whole-catalogue queries on a pool shared with authenticated work.
Local check. {'local': 'In a sandboxed scratch build with no network, write an in-crate #[cfg(test)] test. Use Store::scratch() with IPX_TEST_DATABASE_URL unset, which gives SQLite in memory with one connection (store.rs:102, 141-143). Or, to match production, use Store::open(, true), which gives the fixed 10-connection pool (store.rs:146-147). Seed 5,000 feeds (items set, one snapshot row each). Then spawn 50 concurrent GET /search?q= requests through router() via tower::ServiceExt::oneshot, and meanwhile time one authenticated GET /v1/popular?limit=10. Expected if the claim holds: the /v1 call waits behind the page queries, and its latency grows with the number of concurrent anonymous requests. Regression after a fix (search and filter in SQL with LIMIT and a page size, or a short-lived cached Vec
shared by the pages, plus a concurrency limit on site routes): /v1 latency stays flat.', 'deployment': 'Owner checks the Cloudflare zone for directory.ipxreader.app: whether a Cache Rule caches text/html ignoring query strings and whether any rate-limiting or WAF rule applies to anonymous GETs; and runs EXPLAIN (ANALYZE, BUFFERS) of the Store::list query on ipx_directory inside BEGIN READ ONLY ... ROLLBACK to get its per-call cost and row count.'}Owner check (no probing). Owner checks the Cloudflare zone for directory.ipxreader.app: whether a Cache Rule caches text/html ignoring query strings and whether any rate-limiting or WAF rule applies to anonymous GETs; and runs EXPLAIN (ANALYZE, BUFFERS) of the Store::list query on ipx_directory inside BEGIN READ ONLY ... ROLLBACK to get its per-call cost and row count.
Full detail:
/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md(fingerprintdirectory:site.rs:anonymous-pages-full-catalogue-query-per-request).Fixed in
fc04a74: the public pages share one copy of the list, read at most once a minute behind a mutex held through the read, so a burst waits on one query. Filtering and rendering stay per request, in memory; /v1/feeds still reads the database. Deployed; /, /all and /search answer in 1-45 ms.