A feed's .torrent URL, or a tracker it names, can make the daemon buffer an unbounded body in memory #330

Closed
opened 2026-10-23 05:26:48 -07:00 by rays · 2 comments
Owner

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 pending enclosure whose URL ends in .torrent or whose MIME contains 'torrent' (download.rs:221-224), or whose HTTP body sniffs as a torrent (main.rs:2253-2266), is handed to librqbit as AddTorrent::from_url with the publisher's URL. librqbit 9.0.1 fetches it in torrent_from_url (session.rs:156-173) with reqwest get().send() then response.bytes(): the whole body is buffered in memory with no size limit, on a client built with reqwest defaults (session.rs:706-725: no timeout, redirects followed). ipx's only bound is the outer tokio timeout of stall_mins (default 30) around add_torrent (torrent.rs:60-72). A publisher, or a signed-in user who adds a feed they control, can serve an endless 200 body and the shared daemon keeps buffering it for up to 30 minutes. The same unbounded read applies to each HTTP tracker announce named by a valid .torrent (librqbit-tracker-comms 9.0.1 tracker_comms.rs:293-297), which recurs throughout the transfer, outside the metadata timeout, because ipx leaves disable_trackers at its default false. Two such jobs run at once (torrent_slots = 2, main.rs:374). A failed job is marked 'error' and is not picked up again by pending() (db.rs:885 selects only state = 'pending'), so repetition needs new enclosure URLs on later scans, which the publisher controls. Whether this OOM-kills only the container or pressures the whole host depends on the deployed memory limit.

Claimed root cause. ipx hands publisher-controlled URLs to librqbit's Session (torrent.rs:63, AddTorrent::from_url) instead of fetching and size-capping the .torrent itself, and leaves librqbit's HTTP trackers enabled; librqbit's torrent_from_url and HTTP tracker announce read whole response bodies into memory with response.bytes() and no limit.

Local check. {'local': "In a sandbox with no network beyond loopback, run a scratch daemon on SQLite under a memory cgroup (systemd-run --scope -p MemoryMax=512M). Serve a fixture feed with one enclosure http://127.0.0.1:/ep.torrent whose handler returns 200 and streams zero bytes without end. Subscribe with auto-download on, run one scan, and sample the daemon's RSS every second: vulnerable if RSS rises with bytes served until the cgroup kills it before the 30-minute timeout. Repeat with a valid small .torrent whose announce is a loopback HTTP tracker that streams endlessly. Fix: fetch the .torrent with ctx.client under a size cap (for example 10 MB, reading chunks) and pass AddTorrent::TorrentFileBytes; set SessionOptions.disable_trackers or otherwise bound tracker responses. Regression test: a fixture serving 11 MB as a .torrent fails with a size error and RSS stays flat.", 'deployment': 'Owner reads /mnt/fast/arcane/projects/ipx/compose.yaml for a memory limit on the ipx service (mem_limit or deploy.resources.limits.memory) and confirms whether [torrent] enabled is on in /mnt/fast/appdata/ipx/config.toml; no request to production is needed.'}

Owner check (no probing). Owner reads /mnt/fast/arcane/projects/ipx/compose.yaml for a memory limit on the ipx service (mem_limit or deploy.resources.limits.memory) and confirms whether [torrent] enabled is on in /mnt/fast/appdata/ipx/config.toml; no request to production is needed.

Full detail: /config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md (fingerprint ipx:torrent.rs:fetch:librqbit-unbounded-torrent-and-tracker-body).

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 pending enclosure whose URL ends in .torrent or whose MIME contains 'torrent' (download.rs:221-224), or whose HTTP body sniffs as a torrent (main.rs:2253-2266), is handed to librqbit as AddTorrent::from_url with the publisher's URL. librqbit 9.0.1 fetches it in torrent_from_url (session.rs:156-173) with reqwest get().send() then response.bytes(): the whole body is buffered in memory with no size limit, on a client built with reqwest defaults (session.rs:706-725: no timeout, redirects followed). ipx's only bound is the outer tokio timeout of stall_mins (default 30) around add_torrent (torrent.rs:60-72). A publisher, or a signed-in user who adds a feed they control, can serve an endless 200 body and the shared daemon keeps buffering it for up to 30 minutes. The same unbounded read applies to each HTTP tracker announce named by a valid .torrent (librqbit-tracker-comms 9.0.1 tracker_comms.rs:293-297), which recurs throughout the transfer, outside the metadata timeout, because ipx leaves disable_trackers at its default false. Two such jobs run at once (torrent_slots = 2, main.rs:374). A failed job is marked 'error' and is not picked up again by pending() (db.rs:885 selects only state = 'pending'), so repetition needs new enclosure URLs on later scans, which the publisher controls. Whether this OOM-kills only the container or pressures the whole host depends on the deployed memory limit. **Claimed root cause.** ipx hands publisher-controlled URLs to librqbit's Session (torrent.rs:63, AddTorrent::from_url) instead of fetching and size-capping the .torrent itself, and leaves librqbit's HTTP trackers enabled; librqbit's torrent_from_url and HTTP tracker announce read whole response bodies into memory with response.bytes() and no limit. **Local check.** {'local': "In a sandbox with no network beyond loopback, run a scratch daemon on SQLite under a memory cgroup (systemd-run --scope -p MemoryMax=512M). Serve a fixture feed with one enclosure http://127.0.0.1:<port>/ep.torrent whose handler returns 200 and streams zero bytes without end. Subscribe with auto-download on, run one scan, and sample the daemon's RSS every second: vulnerable if RSS rises with bytes served until the cgroup kills it before the 30-minute timeout. Repeat with a valid small .torrent whose announce is a loopback HTTP tracker that streams endlessly. Fix: fetch the .torrent with ctx.client under a size cap (for example 10 MB, reading chunks) and pass AddTorrent::TorrentFileBytes; set SessionOptions.disable_trackers or otherwise bound tracker responses. Regression test: a fixture serving 11 MB as a .torrent fails with a size error and RSS stays flat.", 'deployment': 'Owner reads /mnt/fast/arcane/projects/ipx/compose.yaml for a memory limit on the ipx service (mem_limit or deploy.resources.limits.memory) and confirms whether [torrent] enabled is on in /mnt/fast/appdata/ipx/config.toml; no request to production is needed.'} **Owner check (no probing).** Owner reads /mnt/fast/arcane/projects/ipx/compose.yaml for a memory limit on the ipx service (mem_limit or deploy.resources.limits.memory) and confirms whether [torrent] enabled is on in /mnt/fast/appdata/ipx/config.toml; no request to production is needed. Full detail: `/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md` (fingerprint `ipx:torrent.rs:fetch:librqbit-unbounded-torrent-and-tracker-body`).
rays added the bug label 2026-10-23 05:26:48 -07:00
Author
Owner

Half fixed in 6d71613, deployed: ipx fetches an http(s) .torrent itself on its own client (connect and read timeouts), refuses one over 10 MB, and keeps the fetch inside the stall budget. librqbit gets the bytes; magnets go to it as before.

Still open: librqbit 9.0.1 reads each HTTP tracker announce with response.bytes() and no limit (librqbit-tracker-comms tracker_comms.rs:293-297). SessionOptions has no setting that bounds it short of disable_trackers, which would break torrents that need their tracker. The ways left are a patched librqbit, or a memory limit on the ipx service in compose.yaml as a backstop (there is none today).

Half fixed in 6d71613, deployed: ipx fetches an http(s) .torrent itself on its own client (connect and read timeouts), refuses one over 10 MB, and keeps the fetch inside the stall budget. librqbit gets the bytes; magnets go to it as before. Still open: librqbit 9.0.1 reads each HTTP tracker announce with response.bytes() and no limit (librqbit-tracker-comms tracker_comms.rs:293-297). SessionOptions has no setting that bounds it short of disable_trackers, which would break torrents that need their tracker. The ways left are a patched librqbit, or a memory limit on the ipx service in compose.yaml as a backstop (there is none today).
Author
Owner

The tracker half now has a backstop: the ipx service in /mnt/fast/arcane/projects/ipx/compose.yaml has mem_limit: 2g (it idles near 120 MB), so a tracker answer without end gets the container killed and restarted, not Tower starved. The .torrent fetch itself was fixed in 6d71613.

The tracker half now has a backstop: the ipx service in /mnt/fast/arcane/projects/ipx/compose.yaml has `mem_limit: 2g` (it idles near 120 MB), so a tracker answer without end gets the container killed and restarted, not Tower starved. The .torrent fetch itself was fixed in 6d71613.
rays closed this issue 2026-10-23 06:40:33 -07:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rays/ipx#330