Cap attribution_link recursion + Downloader response body size

Two adversarial bugs surfaced by the round-2 audit on this crate.

extract_video_id recursion (linkhandler/stream.rs)
  /attribution_link?u=<inner> recursed on the inner URL with no depth
  guard. The comment claimed 'only one level deep' but the call was
  plain recursion — a pasted URL whose u= param decodes to another
  /attribution_link would recurse until the JVM stack blew. Wrap the
  recursion in extract_video_id_inner with an explicit depth counter
  capped at MAX_ATTRIBUTION_DEPTH = 1.

ReqwestDownloader body cap (downloader/default_impl.rs)
  resp.text() read the entire response body into a String with no
  upper bound. Player.js is ~1.5 MB, watch HTML ~3 MB, channel
  responses well under 1 MB. A hostile redirect target (or compromised
  host) could blast multi-GB and OOM-kill the Android process — there
  is no headroom on a 1 GB JVM heap ceiling.

  Cap at 32 MB. Two-stage check: bail fast on a known Content-Length
  that exceeds the cap, and use Read::take(MAX+1) on the stream so we
  detect overrun rather than silently truncate. Switched the final
  decode to from_utf8_lossy so a single mojibake byte doesn't drop the
  whole response (same fix shape as the wrapper's read_capped_body).
This commit is contained in:
Kayos 2026-05-26 22:02:40 -07:00
parent 7d2e4c51b8
commit bfd06d1ef3
2 changed files with 50 additions and 3 deletions

View file

@ -30,6 +30,18 @@ pub fn is_valid_video_id(id: &str) -> bool {
/// the URL doesn't look like a YT video URL (so search results / channel
/// pages return None rather than Err — caller decides).
pub fn extract_video_id(input_url: &str) -> Result<String, LinkError> {
extract_video_id_inner(input_url, 0)
}
/// Internal recursion helper. `depth` is bumped on every
/// /attribution_link?u=... re-entry so a maliciously-nested
/// attribution chain can't blow the stack.
fn extract_video_id_inner(input_url: &str, depth: u8) -> Result<String, LinkError> {
// Cap attribution-link recursion. Real YT never nests these; a
// pasted-URL DoS that recurses ~10k levels would stack-overflow
// the JVM via UniFFI. One level is enough for the legitimate
// share-from-attribution-app case.
const MAX_ATTRIBUTION_DEPTH: u8 = 1;
let url = Url::parse(input_url)
.map_err(|e| LinkError::InvalidUrl(format!("{input_url}: {e}")))?;
let host = url
@ -68,10 +80,15 @@ pub fn extract_video_id(input_url: &str) -> Result<String, LinkError> {
// /attribution_link?u=<encoded watch url>
if candidate.is_none() && path.starts_with("/attribution_link") {
if depth >= MAX_ATTRIBUTION_DEPTH {
return Err(LinkError::InvalidUrl(
"attribution_link recursion exceeded cap".into(),
));
}
if let Some((_, u_param)) = url.query_pairs().find(|(k, _)| k == "u") {
// Recurse on the decoded URL — but only one level deep.
// Recurse on the decoded URL with depth bump.
let inner = format!("https://www.youtube.com{u_param}");
return extract_video_id(&inner);
return extract_video_id_inner(&inner, depth + 1);
}
}