GitHub Security Advisory: GHSA-793q-xgj6-7frp
Package: wwbn/avideo (COMPOSER)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 29.0
Patched in: unpatched

Summary: WWBN AVideo has an incomplete fix for CVE-2026-33039: SSRF

Details:
### Summary

The incomplete SSRF fix in AVideo's LiveLinks proxy adds `isSSRFSafeURL()` validation but leaves DNS TOCTOU vulnerabilities where DNS rebinding between validation and the actual HTTP request redirects traffic to internal endpoints.

### Affected Package

- **Ecosystem:** Other
- **Package:** AVideo
- **Affected versions:** < commit 0e56382921fc71e64829cd1ec35f04e338c70917
- **Patched versions:** >= commit 0e56382921fc71e64829cd1ec35f04e338c70917

### Details

The `plugin/LiveLinks/proxy.php` endpoint proxies live stream URLs. The fix adds `isSSRFSafeURL()` check on the initial URL, redirect URL validation, and `follow_location=0` in the `get_headers()` context. However, multiple DNS TOCTOU vulnerabilities remain.

For the initial URL, `isSSRFSafeURL()` resolves DNS once for validation, but `get_headers()` resolves DNS again independently. A DNS rebinding attack with TTL=0 returns a safe external IP for the first resolution and an internal IP for the second.

The same TOCTOU exists for redirect URLs: `isSSRFSafeURL()` validates the redirect target (first resolution returns a safe IP), then `fakeBrowser()` makes the actual request (second resolution returns an internal IP).

Additionally, even with `follow_location=0`, `get_headers()` still sends an HTTP request that can probe internal services via DNS rebinding, and multiple `Location` headers in a response cause `filter_var()` to receive an array instead of a string, resulting in a fall-through to the else branch.

### PoC

```python
#!/usr/bin/env python3
"""
CVE-2026-33039 - AVideo LiveLinks Proxy SSRF via DNS Rebinding
"""

import re
import sys

class DNSResolver:
    def __init__(self):
        self._call_count = {}

    def resolve(self, host):
        if host not in self._call_count:
            self._call_count[host] = 0
        self._call_count[host] += 1

        if host == "rebind.attacker.com":
            return "93.184.216.34" if self._call_count[host] == 1 else "169.254.169.254"
        if host == "rebind-loopback.attacker.com":
            return "93.184.216.34" if self._call_count[host] == 1 else "127.0.0.1"

        static = {"attacker.com": "93.184.216.34", "example.com": "93.184.216.34", "localhost": "127.0.0.1"}
        return static.get(host, None)

    def reset(self):
        self._call_count = {}

dns = DNSResolver()

def php_parse_url_host(url):
    match = re.match(r'https?://([^/:?#]+)', url, re.IGNORECASE)
    return match.group(1).lower() if match else None

def php_filter_validate_ip(s):
    if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', s):
        return all(0 <= int(p) <= 255 for p in s.split('.'))
    return False

def is_ssrf_safe_url(url):
    if not url: return False, "empty"
    host = php_parse_url_host(url)
    if not host: return False, "no host"

    for pat in ['localhost', '127.0.0.1', '::1', '0.0.0.0']:
        if host == pat: return False, f"blocked: {host}"

    ip = host
    if not php_filter_validate_ip(host):
        resolved =