A deeply nested OPML, uploaded by any signed-in user or served by any feed publisher, can overflow the stack and abort the whole daemon #322
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.
POST /api/opml takes a JSON body of up to axum's default 2 MB from any signed-in user, admin or not, and passes its xml string straight to opml::OPML::from_str. In opml 1.1.6 (Cargo.lock), Outline derives hard-xml 1.41.0's XmlRead and has #[xml(child = "outline")] outlines: Vec; the derived reader (hard-xml-derive read/named.rs:261) calls ::from_reader recursively for each child, and neither hard-xml nor its xmlparser tokenizer has a depth limit. '' plus '' is about 30 bytes a level with JSON escaping, so 2 MB holds roughly 65,000-70,000 levels. Each recursion frame holds an Outline of about fifteen String/Option fields plus reader state, so even at a conservative 100 bytes a frame that is several MB, more than the 2 MiB stack of a tokio worker from the default #[tokio::main] runtime that also runs the axum server (web.rs:107, main.rs:815). If parsing survived, feed::collect_outlines and the drop of the nested Vec recurse again per level. A Rust stack overflow aborts the process (SIGSEGV via the guard page), not the task, so one request stops scanning, downloads, playback and the API for every household user until the container restarts, and it can be repeated. feed::parse_opml and feed::opml_title apply the same parse to fetched OPML feeds, giving a feed publisher a second route via any user who subscribes to the URL. Variant on the fetch path (wave 3): a publisher serving a body that starts with <opml can reach the same recursive parse without any upload. add_one calls feed::opml_title on the body of any URL a signed-in user adds (main.rs:917-918), and every scan of a subscribed OPML feed calls sync_opml, which runs feed::parse_opml and then feed::opml_title (main.rs:2020-2022). The fetch cap is MAX_BODY, 50 MB (feed/src/lib.rs:119), about 25 times the upload limit. The feed stays in the catalogue, so a daemon restarted after the abort scans it again and aborts again: a crash loop that the publisher of an already-subscribed OPML can switch on by changing the document.
Claimed root cause. import_opml, feed::parse_opml and feed::opml_title hand untrusted OPML (up to 2 MB uploaded, up to 50 MB fetched) to a recursive-descent parser (hard-xml's derived Outline reader) and a recursive walker (feed::collect_outlines) with no nesting-depth bound, on a tokio worker thread with the default 2 MiB stack in the single shared daemon.
Local check. {'local': 'In a sandbox with no network, add a test in crates/feed that builds '<head/>' + '' * N + '' * N + '' for N = 1,000, 10,000 and 65,000 (the last about 2 MB) and calls opml::OPML::from_str on it inside std::thread::Builder::new().stack_size(2 << 20).spawn(...), built --release, under ulimit -v and a 30 s timeout. Vulnerable if the process aborts with 'has overflowed its stack' at a depth that fits under 2 MB of JSON. Optionally confirm end to end against a scratch daemon on SQLite: POST {"xml": } to /api/opml as a non-admin dummy user and observe the daemon exit. Fix: before parsing, count nesting with a non-recursive quick-xml or xmlparser pass and refuse documents deeper than a small bound (for example 32), give /api/opml a smaller DefaultBodyLimit, make collect_outlines iterative, and apply the same pre-check in feed::parse_opml and feed::opml_title.', 'deployment': "Owner confirms the iPX container's restart policy and how quickly it comes back after an abort, which decides how long one request's outage lasts, and whether any proxy in front of ipx-web (Cloudflare, Caddy) caps request bodies below 2 MB for /api/opml."}
Owner check (no probing). Owner confirms the iPX container's restart policy and how quickly it comes back after an abort, which decides how long one request's outage lasts, and whether any proxy in front of ipx-web (Cloudflare, Caddy) caps request bodies below 2 MB for /api/opml.
Full detail:
/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md(fingerprintipx:web.rs:import_opml:unbounded-outline-nesting-stack-overflow).Fixed in
f398211:feed::read_opmlcounts nesting with quick-xml's streaming reader and refuses an OPML past 100 levels before the opml crate parses it. The upload,ipx opml, a fetched OPML's feeds and its title all go through it. Deployed; on production a 101-level upload now gets 400 'that OPML nests more than 100 levels deep'.