OPML nested past 100 levels is refused before it is parsed (#322)

The opml crate's Outline reader (hard-xml's derive) recurses once per
<outline> level with no limit. A 2 MB upload holds tens of thousands of
levels, enough to overflow a tokio worker's 2 MiB stack, and a stack
overflow aborts the process, not the task. A fetched OPML (up to 50 MB)
reached the same parse on every scan, so a publisher could keep the
daemon in a crash loop.

feed::read_opml counts nesting with quick-xml's streaming reader, which
does not recurse, and refuses past 100 before handing the text to the
opml crate. Every place that parsed OPML goes through it: the upload,
`ipx opml`, a fetched OPML's feeds and its title. The limit also bounds
collect_outlines' recursion and the drop of the nested Vec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:11:21 +00:00
parent 708b69c083
commit f398211fd6
4 changed files with 49 additions and 5 deletions

View File

@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
## [1.4.0] - 2026-10-19

View File

@@ -977,7 +977,7 @@ async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
.into_iter()
.find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
Ok(())

View File

@@ -2404,8 +2404,8 @@ async fn import_opml(
) -> Result<Json<serde_json::Value>, ApiError> {
// Refused before anything is touched. Nothing reaches the disk either way: an uploaded
// file arrives as text, is read here, and is gone when the request ends.
let doc =
opml::OPML::from_str(&body.xml).map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let doc = crate::feed::read_opml(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
if added > 0 {
scan_soon(&state, None).await;