Downloaded enclosure served same-origin with an extension-guessed type allows stored XSS #319

Closed
opened 2026-10-23 05:26:42 -07:00 by rays · 1 comment
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.

A feed publisher can make ipx auto-download an enclosure with no declared type (always wanted under the default media_types) whose saved name ends .svg, .html, .htm or .xhtml, taken from the publisher's Content-Disposition or URL path. The downloader refuses only bodies whose trimmed start is <!doctype html or <html, so an SVG with a <script> element, or an HTML body starting with <script> or , is kept. /media/{id} serves it through tower-http 0.7.1 ServeFile::new, which sets Content-Type from mime_guess by extension (image/svg+xml, text/html, application/xhtml+xml), and serve_media adds only Cache-Control: private: no Content-Security-Policy, X-Content-Type-Options or Content-Disposition: attachment, and Caddy adds none for /media/*. The page links any downloaded enclosure that isPlayable rejects (no mime, non-media extension) as 'View in a new tab' at /media/{id}; a top-level navigation also carries the SameSite=Lax session cookie, so a direct link works too. Opening it would run the publisher's script on the iPX origin, able to make same-origin authenticated /api calls as the viewer (for an admin, settings and accounts). Any subscriber of the feed who opens the file is affected; serve_media's subscribed() check limits it to them. Variant: a torrent whose largest file (download::playable) has such an extension is served the same way.

Claimed root cause. serve_media (crates/ipx/src/web.rs:2117-2141) serves publisher-named files with ServeFile::new, whose Content-Type is mime_guess's extension guess (tower-http-0.7.1 src/services/fs/serve_file.rs:21-30), and adds no CSP sandbox, nosniff or attachment disposition, while download::sniff refuses only two leading strings. art_response (web.rs:2235-2246) already applies 'default-src none; sandbox' and nosniff to the same class of untrusted bytes; /media does not.

Local check. {'local': 'In the Playwright suite's scratch daemon (/tmp/ipx-ui-test, fixture feeds on 127.0.0.1), add a fixture feed whose item has with no type attribute, served as <script>document.title='xss:'+document.domain</script>. After a scan, as a dummy subscribed user, GET /media/{id} and assert Content-Type image/svg+xml and no Content-Security-Policy, X-Content-Type-Options or Content-Disposition; click 'View in a new tab' and assert the new page's title is 'xss:127.0.0.1'. Repeat with ep.html containing <script>document.title='xss'</script>. Regression after the fix: the response carries Content-Security-Policy: default-src 'none'; sandbox and nosniff (or Content-Disposition: attachment for non-media), and the title is unchanged.', 'deployment': 'Owner checks, read only, that a /media/{id} response through ipodderx.sdf1.net (Cloudflare tunnel and Access) carries no added Content-Security-Policy or Content-Disposition from Cloudflare transform rules, i.e. that production serves the same headers as the source.'}

Owner check (no probing). Owner checks, read only, that a /media/{id} response through ipodderx.sdf1.net (Cloudflare tunnel and Access) carries no added Content-Security-Policy or Content-Disposition from Cloudflare transform rules, i.e. that production serves the same headers as the source.

Full detail: /config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md (fingerprint ipx/media/served-inline-by-extension).

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. A feed publisher can make ipx auto-download an enclosure with no declared type (always wanted under the default media_types) whose saved name ends .svg, .html, .htm or .xhtml, taken from the publisher's Content-Disposition or URL path. The downloader refuses only bodies whose trimmed start is <!doctype html or <html, so an SVG with a <script> element, or an HTML body starting with <script> or <body>, is kept. /media/{id} serves it through tower-http 0.7.1 ServeFile::new, which sets Content-Type from mime_guess by extension (image/svg+xml, text/html, application/xhtml+xml), and serve_media adds only Cache-Control: private: no Content-Security-Policy, X-Content-Type-Options or Content-Disposition: attachment, and Caddy adds none for /media/*. The page links any downloaded enclosure that isPlayable rejects (no mime, non-media extension) as 'View in a new tab' at /media/{id}; a top-level navigation also carries the SameSite=Lax session cookie, so a direct link works too. Opening it would run the publisher's script on the iPX origin, able to make same-origin authenticated /api calls as the viewer (for an admin, settings and accounts). Any subscriber of the feed who opens the file is affected; serve_media's subscribed() check limits it to them. Variant: a torrent whose largest file (download::playable) has such an extension is served the same way. **Claimed root cause.** serve_media (crates/ipx/src/web.rs:2117-2141) serves publisher-named files with ServeFile::new, whose Content-Type is mime_guess's extension guess (tower-http-0.7.1 src/services/fs/serve_file.rs:21-30), and adds no CSP sandbox, nosniff or attachment disposition, while download::sniff refuses only two leading strings. art_response (web.rs:2235-2246) already applies 'default-src none; sandbox' and nosniff to the same class of untrusted bytes; /media does not. **Local check.** {'local': 'In the Playwright suite\'s scratch daemon (/tmp/ipx-ui-test, fixture feeds on 127.0.0.1), add a fixture feed whose item has <enclosure url="http://127.0.0.1:<fixture>/ep.svg"> with no type attribute, served as <svg xmlns="http://www.w3.org/2000/svg"><script>document.title=\'xss:\'+document.domain</script></svg>. After a scan, as a dummy subscribed user, GET /media/{id} and assert Content-Type image/svg+xml and no Content-Security-Policy, X-Content-Type-Options or Content-Disposition; click \'View in a new tab\' and assert the new page\'s title is \'xss:127.0.0.1\'. Repeat with ep.html containing <script>document.title=\'xss\'</script>. Regression after the fix: the response carries Content-Security-Policy: default-src \'none\'; sandbox and nosniff (or Content-Disposition: attachment for non-media), and the title is unchanged.', 'deployment': 'Owner checks, read only, that a /media/{id} response through ipodderx.sdf1.net (Cloudflare tunnel and Access) carries no added Content-Security-Policy or Content-Disposition from Cloudflare transform rules, i.e. that production serves the same headers as the source.'} **Owner check (no probing).** Owner checks, read only, that a /media/{id} response through ipodderx.sdf1.net (Cloudflare tunnel and Access) carries no added Content-Security-Policy or Content-Disposition from Cloudflare transform rules, i.e. that production serves the same headers as the source. Full detail: `/config/security-audit-skill/ipx/run-1/NEEDS-VALIDATION.md` (fingerprint `ipx/media/served-inline-by-extension`).
rays added the bug label 2026-10-23 05:26:42 -07:00
Author
Owner

Fixed in 62a2456: every /media response is nosniff. Anything but audio, video, a non-SVG image, a PDF or plain text is sent as an attachment, and everything but a PDF gets the sandbox CSP. Deployed; checked on production that an MP3 comes back audio/mpeg, inline, with nosniff and the CSP.

Fixed in 62a2456: every /media response is nosniff. Anything but audio, video, a non-SVG image, a PDF or plain text is sent as an attachment, and everything but a PDF gets the sandbox CSP. Deployed; checked on production that an MP3 comes back audio/mpeg, inline, with nosniff and the CSP.
rays closed this issue 2026-10-23 06:02:56 -07:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rays/ipx#319