From 49fe7b08522730ee123921a97b81a420747fe9bd Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 13 Oct 2026 09:35:18 +0200 Subject: [PATCH] feat: port audio proxy to Rust Streams /api/audio/proxy with manual redirect handling, range support, final-URL caching and SSRF checks on every hop. Co-Authored-By: Claude Opus 5.5 --- backend/Cargo.toml | 3 +- backend/src/audio.rs | 203 +++++++++++++++++++++++++++++++++++++++++++ backend/src/main.rs | 6 ++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 backend/src/audio.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ead8461..c10f9c2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,7 +15,7 @@ futures = "0.3.30" jsonwebtoken = "9.3.0" mongodb = "2.8.2" redis = { version = "0.25.4", features = ["tokio-comp", "connection-manager"] } -reqwest = { version = "0.12.5", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] } +reqwest = { version = "0.12.5", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate", "stream"] } rss = "2.0.8" serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.120" @@ -23,6 +23,7 @@ tokio = { version = "1.38.0", features = ["full"] } tower-http = { version = "0.5.2", features = ["fs", "cors", "set-header", "trace"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +url = "2.5.2" [profile.release] opt-level = 3 diff --git a/backend/src/audio.rs b/backend/src/audio.rs new file mode 100644 index 0000000..f4c7491 --- /dev/null +++ b/backend/src/audio.rs @@ -0,0 +1,203 @@ +//! Streaming audio proxy so the frontend can analyse cross-origin podcast +//! audio with the Web Audio API (`GET /api/audio/proxy?url=...`). + +use std::{ + collections::HashMap, + net::IpAddr, + sync::Mutex, + time::{Duration, Instant}, +}; + +use axum::{ + body::Body, + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use url::Url; + +use crate::{error::AppError, AppState}; + +const MAX_REDIRECTS: usize = 8; +const URL_CACHE_TTL: Duration = Duration::from_secs(10 * 60); +const FORWARDED_HEADERS: [header::HeaderName; 4] = [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_RANGE, + header::ACCEPT_RANGES, +]; + +/// Remembers the final URL of a redirect chain so range requests (seeks) +/// skip the redirects. +#[derive(Default)] +pub struct UrlCache(Mutex>); + +impl UrlCache { + fn get(&self, original: &str) -> Option { + let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner()); + match map.get(original) { + Some((url, expires)) if *expires > Instant::now() => Some(url.clone()), + Some(_) => { + map.remove(original); + None + } + None => None, + } + } + + fn set(&self, original: &str, resolved: &str) { + let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let now = Instant::now(); + if map.len() > 5_000 { + map.retain(|_, (_, expires)| *expires > now); + } + map.insert(original.to_owned(), (resolved.to_owned(), now + URL_CACHE_TTL)); + } +} + +/// Client used by the proxy: redirects are followed manually so every hop +/// goes through [`check_url`]. +pub fn client() -> reqwest::Result { + reqwest::Client::builder() + .user_agent("Mozilla/5.0 (compatible; Podcastic/1.0)") + .redirect(reqwest::redirect::Policy::none()) + .no_gzip() + .no_brotli() + .no_deflate() + .connect_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(90)) + .build() +} + +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.octets()[0] == 0 + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || (v6.segments()[0] & 0xfe00) == 0xfc00 + || (v6.segments()[0] & 0xffc0) == 0xfe80 + || v6.to_ipv4_mapped().is_some_and(|v4| is_private_ip(IpAddr::V4(v4))) + } + } +} + +/// Only public http(s) hosts may be proxied. +fn check_url(url: &Url) -> Result<(), AppError> { + if !matches!(url.scheme(), "http" | "https") { + return Err(AppError::bad_request("Protocol not allowed")); + } + let forbidden = || AppError::new(StatusCode::FORBIDDEN, "Forbidden"); + match url.host() { + Some(url::Host::Ipv4(ip)) if is_private_ip(IpAddr::V4(ip)) => Err(forbidden()), + Some(url::Host::Ipv6(ip)) if is_private_ip(IpAddr::V6(ip)) => Err(forbidden()), + // Single-label names (localhost, docker service names...) are internal. + Some(url::Host::Domain(domain)) + if !domain.trim_end_matches('.').contains('.') + || domain.eq_ignore_ascii_case("localhost") + || domain.to_ascii_lowercase().ends_with(".localhost") => + { + Err(forbidden()) + } + None => Err(AppError::bad_request("Invalid URL")), + _ => Ok(()), + } +} + +pub async fn proxy( + State(state): State, + Query(params): Query>, + headers: HeaderMap, +) -> Result { + let raw = params + .get("url") + .filter(|u| !u.is_empty()) + .ok_or_else(|| AppError::bad_request("url required"))?; + let original = Url::parse(raw).map_err(|_| AppError::bad_request("Invalid URL"))?; + check_url(&original)?; + + let range = headers.get(header::RANGE).cloned(); + let mut url = state + .audio_urls + .get(raw) + .and_then(|cached| Url::parse(&cached).ok()) + .unwrap_or(original); + + for hop in 0..=MAX_REDIRECTS { + check_url(&url)?; + let mut request = state.audio_http.get(url.clone()).header(header::ACCEPT, "*/*"); + if let Some(range) = &range { + request = request.header(header::RANGE, range.clone()); + } + let upstream = request + .send() + .await + .map_err(|_| AppError::new(StatusCode::BAD_GATEWAY, "Proxy error"))?; + + if upstream.status().is_redirection() { + if let Some(location) = upstream.headers().get(header::LOCATION) { + let next = location + .to_str() + .ok() + .and_then(|loc| url.join(loc).ok()) + .ok_or_else(|| AppError::bad_request("Invalid redirect URL"))?; + url = next; + continue; + } + } + + if hop > 0 { + state.audio_urls.set(raw, url.as_str()); + } + return Ok(stream_response(upstream)); + } + + Err(AppError::new(StatusCode::BAD_GATEWAY, "Too many redirects")) +} + +fn stream_response(upstream: reqwest::Response) -> Response { + let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let mut headers = HeaderMap::new(); + for name in FORWARDED_HEADERS { + if let Some(value) = upstream.headers().get(name.as_str()) { + if let Ok(value) = HeaderValue::from_bytes(value.as_bytes()) { + headers.insert(name, value); + } + } + } + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); + headers.insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("Content-Length, Content-Range, Content-Type, Accept-Ranges"), + ); + + (status, headers, Body::from_stream(upstream.bytes_stream())).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowed(url: &str) -> bool { + check_url(&Url::parse(url).unwrap()).is_ok() + } + + #[test] + fn blocks_internal_hosts() { + assert!(allowed("https://cdn.example.com/a.mp3")); + assert!(!allowed("http://localhost/a.mp3")); + assert!(!allowed("http://127.0.0.1/a.mp3")); + assert!(!allowed("http://192.168.1.10/a.mp3")); + assert!(!allowed("http://169.254.169.254/latest")); + assert!(!allowed("http://[::1]/a.mp3")); + assert!(!allowed("http://redis:6379/")); + assert!(!allowed("ftp://example.com/a.mp3")); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 6716085..57501bb 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,4 @@ +mod audio; mod auth; mod db; mod episodes; @@ -40,6 +41,8 @@ pub struct AppState { pub redis: ConnectionManager, pub http: reqwest::Client, pub jwt: Arc, + pub audio_http: reqwest::Client, + pub audio_urls: Arc, limiter: Arc, } @@ -138,6 +141,7 @@ fn api_router(state: &AppState) -> Router { .nest("/auth", auth) .nest("/podcasts", podcasts) .nest("/episodes", episodes) + .route("/audio/proxy", get(audio::proxy)) } #[tokio::main] @@ -176,6 +180,8 @@ async fn main() -> anyhow::Result<()> { &var("JWT_REFRESH_EXPIRES_IN", "30d"), )), limiter: Arc::new(RateLimiter::new(Duration::from_secs(15 * 60), 300)), + audio_http: audio::client()?, + audio_urls: Arc::new(audio::UrlCache::default()), }; feeds::start_background_sync(state.clone());