set_playlist: unrestricted URL fetch (SSRF) with unbounded response buffering #4

Closed
opened 2026-08-26 01:53:03 +02:00 by benjamin · 1 comment
Owner

Problem

A paired client can send set_playlist with any URL. SourceRepository.open() (SourceRepository.kt:99-102) creates an HttpURLConnection with redirects enabled and no scheme or host allow-list, then SourceRepository.kt:109 reads the whole body with bufferedReader().use { it.readText() } — no size cap.

Failure scenarios

Reach into the LAN and read the answer. The client points the TV at an internal address it cannot reach itself (router admin page, an internal service). The response comes back: M3uParser treats every non-# line as a channel (Channel(name = line, url = line, …)), and those lines are broadcast verbatim to the client as the channel list. Line-oriented response bodies are exfiltrated straight into the remote UI.

Out-of-memory crash. readTimeout is a per-read socket deadline, not an overall transfer limit, so a hostile server can trickle bytes forever while the String grows unbounded. The 10,000-channel cap in the parser only applies after the full body is already in memory. Any paired client can crash the app with a multi-gigabyte or slow-drip response.

Note: file:// is not reachable — the hard cast to HttpURLConnection throws for non-HTTP schemes and the exception is swallowed.

Suggested fix

  • Restrict to http/https and reject private/loopback/link-local targets (and re-check after each redirect)
  • Cap the read at a few megabytes and abort beyond it
  • Add an overall transfer deadline, not just a per-read timeout
  • Require the parsed result to look like M3U before accepting it (see the parser issue)

Found by multi-agent code review; verified against current SourceRepository.kt.

## Problem A paired client can send `set_playlist` with any URL. `SourceRepository.open()` (`SourceRepository.kt:99-102`) creates an `HttpURLConnection` with redirects enabled and no scheme or host allow-list, then `SourceRepository.kt:109` reads the whole body with `bufferedReader().use { it.readText() }` — no size cap. ## Failure scenarios **Reach into the LAN and read the answer.** The client points the TV at an internal address it cannot reach itself (router admin page, an internal service). The response comes back: `M3uParser` treats every non-`#` line as a channel (`Channel(name = line, url = line, …)`), and those lines are broadcast verbatim to the client as the channel list. Line-oriented response bodies are exfiltrated straight into the remote UI. **Out-of-memory crash.** `readTimeout` is a per-read socket deadline, not an overall transfer limit, so a hostile server can trickle bytes forever while the `String` grows unbounded. The 10,000-channel cap in the parser only applies after the full body is already in memory. Any paired client can crash the app with a multi-gigabyte or slow-drip response. Note: `file://` is not reachable — the hard cast to `HttpURLConnection` throws for non-HTTP schemes and the exception is swallowed. ## Suggested fix - Restrict to `http`/`https` and reject private/loopback/link-local targets (and re-check after each redirect) - Cap the read at a few megabytes and abort beyond it - Add an overall transfer deadline, not just a per-read timeout - Require the parsed result to look like M3U before accepting it (see the parser issue) Found by multi-agent code review; verified against current `SourceRepository.kt`.
Author
Owner

Fixed in dd612fc, shipped in v0.9.0.

  • open() now validates the scheme and rejects anything that is not http/https before opening the connection.
  • download() streams into a builder with a hard 24 MB cap and an overall 120 s transfer deadline, so a slow-drip or oversized response fails fast instead of growing the buffer until the app dies.
  • The exfiltration half is closed from the other side too: M3uParser now rejects payloads without playlist markers (#10), so a fetched HTML page or JSON body no longer turns into "channels" that get broadcast back to the client.

What remains by design: the target host is not restricted to public addresses. A LAN address is exactly what the generic source is for (ErsatzTV, Threadfin, a local Dispatcharr), and only an already-paired client can call set_playlist — the pairing path itself is what got hardened in #2.

Fixed in dd612fc, shipped in v0.9.0. - `open()` now validates the scheme and rejects anything that is not `http`/`https` before opening the connection. - `download()` streams into a builder with a hard 24 MB cap and an overall 120 s transfer deadline, so a slow-drip or oversized response fails fast instead of growing the buffer until the app dies. - The exfiltration half is closed from the other side too: `M3uParser` now rejects payloads without playlist markers (#10), so a fetched HTML page or JSON body no longer turns into "channels" that get broadcast back to the client. What remains by design: the target host is not restricted to public addresses. A LAN address is exactly what the generic source is for (ErsatzTV, Threadfin, a local Dispatcharr), and only an already-paired client can call `set_playlist` — the pairing path itself is what got hardened in #2.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: be-nj/castarr#4