{"schema":"threatline.record/2","record":{"id":"GHSA-m8q2-35pq-qrmc","aliases":["GHSA-m8q2-35pq-qrmc"],"summary":"Blind SSRF via redirect-following in getHeaderContentTypeFromURL() (bypasses isSSRFSafeURL)","details":"# Blind SSRF via redirect-following in getHeaderContentTypeFromURL() (bypasses isSSRFSafeURL)\r\n\r\n- **Severity:** Low (maintainer assessment: authenticated, blind, GET-only SSRF with a boolean render-type oracle)\r\n- **Affected asset:** WWBN/AVideo, `master` branch, commit `8a32cc70a4` (HEAD at audit time)\r\n- **Reporter:** (this audit)\r\n- **Date:** 2026-10-05\r\n- **Status:** Confirmed and fixed; no CVE assigned at publication.\r\n\r\n## Summary\r\n\r\n`getHeaderContentTypeFromURL()` (`objects/functions.php:6124`) validates a video's `videoLink` with `isValidURL()` and `isSSRFSafeURL()` before calling `get_headers($url, 1)` — but `get_headers()` follows HTTP redirects by default (PHP's default `http` wrapper has `follow_location = 1`) with **no re-validation of the redirect target**. Every other outbound-fetch path in this codebase (`url_get_contents()`, `plugin/LiveLinks/proxy.php`) explicitly disables redirect-following and manually re-validates each hop with `isSSRFSafeURL()` — this function is the one gap that doesn't follow that pattern, re-opening the exact SSRF class the surrounding code (and its own inline comment) claims to have closed.\r\n\r\n\r\n## Affected versions\r\n\r\nTested on branch `master`, commit `8a32cc70a4` (2026-10-05/06). This project ships rolling weekly releases from `master` with no in-repo semantic version string (per `RELEASING.md`), so the affected range is expressed using commits: deployments containing the vulnerable function before fix commit `2db920d3326729743511a27de44034b0a6c78b05`, including the tested commit, are affected.\r\n\r\n## Reproduction steps\r\n\r\n1. As any user with upload/add-video-by-URL rights, create a video with `videoLink` pointing at an attacker-controlled server:\r\n   ```\r\n   POST /objects/videoAddNew.json.php\r\n   videoLink=http://attacker.example/redir\r\n   ```\r\n   (`objects/videoAddNew.json.php:88-146` stores this via `$obj->setVideoLink($_POST['videoLink'])`.)\r\n2. Attacker's server at `http://attacker.example/redir` responds with:\r\n   ```\r\n   HTTP/1.1 302 Found\r\n   Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/\r\n   ```\r\n   (or any internal host:port, e.g. `http://127.0.0.1:8500/` for an internal admin service).\r\n3. Any visitor (including unauthenticated) opens the stored video's page. This triggers `Video::getIncludeType($video)` (`objects/video.php:6930`), which for `videoTypeLinkVideo` without `m3u8` in the link calls `isHTMLPage($video['videoLink'])` (`objects/functions.php:6078`), which calls `getHeaderContentTypeFromURL($url)` (`:6124`).\r\n4. `getHeaderContentTypeFromURL()` passes the initial-host check via `isSSRFSafeURL()`, then calls `get_headers($url, 1)`, which transparently follows the 302 to the internal target and returns its `Content-Type` header — a real server-side outbound request to attacker-chosen internal infrastructure, with the HTTP response's `Content-Type` observable indirectly through whether the video renders as `'embed'` (text/html match) or `'video'`.\r\n\r\nVerified empirically during the audit: a local 302 redirect from an SSRF-safe host to `http://127.0.0.1:8900/secret` was followed transparently by `get_headers()` and its `Content-Type` was returned — confirming the primitive.\r\n\r\n## Impact\r\n\r\nBlind SSRF: the server makes a real outbound GET request to any internal host/port an attacker can redirect to (cloud metadata endpoints, internal admin panels, non-exposed services), with no scheme/host restriction beyond the first hop. While the fetched content is not returned verbatim to the attacker, the `embed` vs. `video` render-type choice functions as a boolean oracle (`text/html` present vs. absent vs. connection failure), enabling internal port-scanning / service-fingerprinting and confirming reachability of internal-only endpoints — a meaningful step in further internal attacks (e.g. confirming a cloud metadata service is reachable before chaining with another primitive).\r\n\r\n## Proof of concept\r\n\r\n```\r\n# 1. Attacker-controlled redirector:\r\nGET http://attacker.example/redir  ->  302 Location: http://127.0.0.1:8900/internal-only\r\n\r\n# 2. Plant as upload-capable user:\r\nPOST /objects/videoAddNew.json.php\r\nvideoLink=http://attacker.example/redir\r\n\r\n# 3. Any visitor opens the video's page -> server-side:\r\nVideo::getIncludeType() -> isHTMLPage() -> getHeaderContentTypeFromURL()\r\n  -> isSSRFSafeURL('http://attacker.example/redir')  == true (attacker.example is public/safe)\r\n  -> get_headers('http://attacker.example/redir', 1)  follows 302 -> fetches http://127.0.0.1:8900/internal-only server-side\r\n```\r\n\r\nCode path confirmed by direct source read:\r\n```php\r\n// objects/functions.php:6124\r\nfunction getHeaderContentTypeFromURL($url)\r\n{\r\n    // SECURITY: isValidURL() only checks format, not destination - require isSSRFSafeURL()\r\n    // since $url here can be an attacker-controlled videoLink (Video::getIncludeType()).\r\n    if (isValidURL($url) && isSSRFSafeURL($url) && $type = get_headers($url, 1)[\"Content-Type\"]) {\r\n        return $type;\r\n    }\r\n    return false;\r\n}\r\n```\r\n`isSSRFSafeURL()` only validates the URL passed in — it has no hook into `get_headers()`'s redirect-following, so a safe initial URL that 302s to an unsafe one is never re-checked.\r\n\r\n## Remediation\r\n\r\nUpdate to master commit [`2db920d3326729743511a27de44034b0a6c78b05`](https://github.com/WWBN/AVideo/commit/2db920d3326729743511a27de44034b0a6c78b05) (2026-10-07) or a later revision containing this fix. Deployments before this commit that contain the vulnerable function are affected. The fix disables automatic redirect following, resolves redirects manually, and validates every redirect destination with `isSSRFSafeURL()` before requesting it. Regression tests are included in `tests/Security/HeaderContentTypeRedirectTest.php` and `tests/Security/SecurityHardeningRegressionTest.php`.\r\n\r\nThe following were the original remediation suggestions:\r\n\r\n- Disable redirect-following for this specific check and treat a redirect response as \"unknown / not HTML\" rather than following it:\r\n  ```php\r\n  $context = stream_context_create(['http' => ['follow_location' => 0]]);\r\n  $headers = @get_headers($url, 1, $context);\r\n  ```\r\n- Or, preferably, replace this ad-hoc `get_headers()` call with the codebase's own hardened fetch helper (`url_get_contents()` / the manual-redirect-revalidation pattern already used in `plugin/LiveLinks/proxy.php`), which re-runs `isSSRFSafeURL()` on every redirect hop before following it.\r\n- Audit for any other direct `get_headers()` / `file_get_contents()` / `curl` call in the codebase that passes an `isSSRFSafeURL()`-checked URL without also disabling or re-validating redirects — this audit only confirmed this one call site as reachable with attacker-controlled input; the same class of bug could exist elsewhere.\r\n\r\n## Relationship to prior advisories\r\n\r\nThis is an **incomplete fix** of the already-published `GHSA-vjgr-5x63-cq96` (\"Authenticated blind SSRF via stored videoLink get_headers() in getHeaderContentTypeFromURL\"), not a duplicate. That advisory was filed against a version of `getHeaderContentTypeFromURL()` that had **no** `isSSRFSafeURL()` call at all. The vendor's fix (visible in the current code, with an explicit `// SECURITY:` comment) added the `isSSRFSafeURL($url)` check exactly as that advisory's \"Suggested Fix\" recommended. This report documents that the fix is incomplete: `isSSRFSafeURL()` only validates the *initial* URL, and `get_headers()` still follows redirects via PHP's default stream context with no re-validation of the redirect target — reopening the identical SSRF primitive through a bypass of the patch itself.\r\n\r\n## References\r\n\r\n- CWE-918 (Server-Side Request Forgery)\r\n- Contrast with the correct pattern already present in this repo at `plugin/LiveLinks/proxy.php` (DNS-pinned, re-validates every redirect hop)\r\n- Incomplete fix of `GHSA-vjgr-5x63-cq96` — see \"Relationship to prior advisories\" above\r\n- Report to: developer@streamphp.com per `.github/SECURITY.md`\r\n","categories":["Unclassified"],"product":"WWBN/AVideo","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-m8q2-35pq-qrmc","source_modified_at":"2026-10-07T13:40:58Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"avideo (source deployments)","name":"WWBN/AVideo"},"range":"master before commit 2db920d3326729743511a27de44034b0a6c78b05"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-m8q2-35pq-qrmc","source_modified_at":"2026-10-07T13:40:58Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"avideo (source deployments)","name":"WWBN/AVideo"},"version":"2db920d3326729743511a27de44034b0a6c78b05"}],"cvss":null,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-07T13:40:58Z","modified_at":"2026-10-07T13:40:58Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-m8q2-35pq-qrmc","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-07T13:40:58Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-07T13:40:58Z","content_hash":"bb28b533e3290167ba5f7f901741c4d9e9e533394bf27816474636a45e7c9b5e"}}
