One OPML upload of many same-titled outlines does quadratic work while holding the catalogue lock, and adds tens of thousands of permanent feeds to the shared scan #338
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.
Any signed-in user, admin or not, can POST up to axum's default 2 MB of OPML to /api/opml (web.rs:63, 2363-2377). No route in the crate overrides DefaultBodyLimit, and no cap applies to outlines per import or feeds per user. subscribe_opml (main.rs:992-1051) takes the global catalogue lock through edit_cfg (main.rs:236-240) and keeps it across the whole outline loop and store_cfg. For each outline it scans all known subscriptions and the growing cfg.feeds map linearly (main.rs:1001-1006). It then calls config::unique_slug (config.rs:499-505), which tries base-2, base-3 and so on until a free key turns up, allocating a String for every attempt. So k outlines with the same title cost about k^2/2 BTreeMap probes, plus a similar number of string compares in the linear finds. About 40,000 outlines of roughly 50 bytes each fit in 2 MB, which comes to about 8e8 probes run inline on a tokio worker with no yield. Every other catalogue or settings change waits on cfg_write for the whole time. store_config (db.rs:1613-1630) then deletes and rewrites every catalogue row in one transaction. Because it rewrites the full catalogue on every later store_cfg, every later catalogue change from any user also pays for all the inflated rows. Each new URL becomes a permanent catalogue feed plus a subscription (main.rs:1042-1049), and scan_soon (web.rs:2374) puts them all on the shared worker's scan. Only an admin can clean them up. One non-admin request can therefore slow or stall catalogue changes and scanning for the whole household, and the slowdown stays after the request ends.
Claimed root cause. subscribe_opml has no cap on outlines per import or feeds per user. Its per-outline dedupe (linear find over known subscriptions and cfg.feeds) and naming (unique_slug probes suffixes in order) each cost O(n), so one import costs O(n^2) while holding cfg_write. store_config's rewrite of the full catalogue makes the larger catalogue a lasting cost on every later change.
Local check. {'local': 'In a sandbox with no network, add a crates/ipx test on Db::memory with one dummy non-admin user. Build an OPML of n outlines, all with text="x" and distinct xmlUrl http://127.0.0.1:9/. Call subscribe_opml for n = 1000, 2000, 4000 and 8000, each under a 60 s timeout. Record the wall time and how long a concurrent ctx.edit_cfg() waits to acquire the lock. It is vulnerable if the time roughly quadruples per doubling and the catalogue grows by n. Then time one unrelated store_cfg afterwards to show the lasting cost. Fix: refuse imports above a cap (for example 500 outlines), dedupe with a HashSet/HashMap of URLs built once before the loop, give unique_slug a per-base next-suffix map (or a suffix from a counter), and consider a per-user subscription cap.', 'deployment': "The owner checks, without load, whether production's reverse proxy (ipx-web Caddy, Cloudflare) imposes a request body limit below 2 MB on /api/opml, and how many non-admin accounts exist. No upload against production."}
Owner check (no probing). The owner checks, without load, whether production's reverse proxy (ipx-web Caddy, Cloudflare) imposes a request body limit below 2 MB on /api/opml, and how many non-admin accounts exist. No upload against production.
Full detail:
/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md(fingerprintipx:main.rs:subscribe_opml:quadratic-slug-under-cfg-lock).Fixed in
a20fade: an OPML upload may add up to 2,000 feeds (the limit #337 gave subscribed lists); more is a 400 and nothing changes. The per-outline search of every subscription and the catalogue is now a map built once. Deployed.