import logging from dataclasses import dataclass, field from fastapi import APIRouter, Request, Query, HTTPException from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask from pydantic import BaseModel from limiter import limiter from auth import require_admin logger = logging.getLogger(__name__) router = APIRouter() _CCTV_PROXY_CONNECT_TIMEOUT_S = 2.0 _CCTV_PROXY_ALLOWED_HOSTS = { "s3-eu-west-1.amazonaws.com", "jamcams.tfl.gov.uk", "images.data.gov.sg", "cctv.austinmobility.io", "webcams.nyctmc.org", "cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov", "images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com", "navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov", "vss1live.dot.ga.gov", "vss2live.dot.ga.gov", "vss3live.dot.ga.gov", "vss4live.dot.ga.gov", "vss5live.dot.ga.gov", "511ga.org", "gettingaroundillinois.com", "cctv.travelmidwest.com", "mdotjboss.state.mi.us", "micamerasimages.net", "publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org", "publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org", "cocam.carsprogram.org", "tripcheck.com", "www.tripcheck.com", "infocar.dgt.es", "informo.madrid.es", "www.windy.com", "imgproxy.windy.com", "www.lakecountypassage.com", "webcam.forkswa.com", "webcam.sunmountainlodge.com", "www.nps.gov", "home.lewiscounty.com", "www.seattle.gov", } @dataclass(frozen=True) class _CCTVProxyProfile: name: str timeout: tuple = (_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0) cache_seconds: int = 30 headers: dict = field(default_factory=dict) def _cctv_host_allowed(hostname) -> bool: host = str(hostname or "").strip().lower() if not host: return False for allowed in _CCTV_PROXY_ALLOWED_HOSTS: normalized = str(allowed or "").strip().lower() if host == normalized or host.endswith(f".{normalized}"): return True return False def _proxied_cctv_url(target_url: str) -> str: from urllib.parse import quote return f"/api/cctv/media?url={quote(target_url, safe='')}" def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile: from urllib.parse import urlparse parsed = urlparse(target_url) host = str(parsed.hostname or "").strip().lower() path = str(parsed.path or "").strip().lower() if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}: return _CCTVProxyProfile(name="tfl-jamcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=15, headers={"Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/"}) if host == "images.data.gov.sg": return _CCTVProxyProfile(name="lta-singapore", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}) if host == "cctv.austinmobility.io": return _CCTVProxyProfile(name="austin-mobility", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=15, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"}) if host == "webcams.nyctmc.org": return _CCTVProxyProfile(name="nyc-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=15, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}) if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}: return _CCTVProxyProfile(name="caltrans", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 15.0), cache_seconds=15, headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8", "Referer": "https://cwwp2.dot.ca.gov/"}) if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}: return _CCTVProxyProfile(name="wsdot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}) if host in {"www.lakecountypassage.com", "webcam.forkswa.com", "webcam.sunmountainlodge.com", "home.lewiscounty.com", "www.seattle.gov"}: return _CCTVProxyProfile(name="regional-cctv-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=45, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": f"https://{host}/"}) if host == "www.nps.gov": return _CCTVProxyProfile(name="nps-webcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=60, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://www.nps.gov/"}) if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}: read_timeout = 18.0 if "/snapshots/" in path else 12.0 return _CCTVProxyProfile(name="gdot-snapshot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, read_timeout), cache_seconds=15, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "http://navigator-c2c.dot.ga.gov/"}) if host == "511ga.org": return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=15, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://511ga.org/cctv"}) if host.startswith("vss") and host.endswith("dot.ga.gov"): return _CCTVProxyProfile(name="gdot-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10, headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8", "Referer": "http://navigator-c2c.dot.ga.gov/"}) if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}: return _CCTVProxyProfile(name="illinois-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}) if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}: return _CCTVProxyProfile(name="michigan-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://mdotjboss.state.mi.us/"}) if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org", "publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org"}: return _CCTVProxyProfile(name="cotrip-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10, headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8", "Referer": "https://www.cotrip.org/"}) if host == "cocam.carsprogram.org": return _CCTVProxyProfile(name="cotrip-preview", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=20, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://www.cotrip.org/"}) if host in {"tripcheck.com", "www.tripcheck.com"}: return _CCTVProxyProfile(name="odot-tripcheck", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}) if host == "infocar.dgt.es": return _CCTVProxyProfile(name="dgt-spain", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=60, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://infocar.dgt.es/"}) if host == "informo.madrid.es": return _CCTVProxyProfile(name="madrid-city", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://informo.madrid.es/"}) if host in {"www.windy.com", "imgproxy.windy.com"}: return _CCTVProxyProfile(name="windy-webcams", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=60, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://www.windy.com/"}) return _CCTVProxyProfile(name="generic-cctv", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=30, headers={"Accept": "*/*"}) def _cctv_upstream_headers(request: Request, profile: _CCTVProxyProfile) -> dict: # Round 7a: per-install operator handle. Mozilla/5.0 prefix retained # because many CCTV endpoints sniff for a browser-like prefix. from services.network_utils import outbound_user_agent headers = { "User-Agent": f"Mozilla/5.0 (compatible; {outbound_user_agent('cctv-proxy')})", **profile.headers, } range_header = request.headers.get("range") if range_header: headers["Range"] = range_header if_none_match = request.headers.get("if-none-match") if if_none_match: headers["If-None-Match"] = if_none_match if_modified_since = request.headers.get("if-modified-since") if if_modified_since: headers["If-Modified-Since"] = if_modified_since return headers def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True) -> dict: headers = {"Cache-Control": f"public, max-age={cache_seconds}", "Access-Control-Allow-Origin": "*"} for key in ("Accept-Ranges", "Content-Range", "ETag", "Last-Modified"): value = resp.headers.get(key) if value: headers[key] = value if include_length: content_length = resp.headers.get("Content-Length") if content_length: headers["Content-Length"] = content_length return headers # Maximum number of redirects we'll follow on the CCTV upstream. Each hop is # re-validated against _cctv_host_allowed() before continuing, so this caps # the redirect-chain SSRF blast radius. _CCTV_MAX_REDIRECTS = 5 def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile): """Fetch an upstream CCTV URL, following redirects manually with host re-validation. Why manual redirect following: The original code used ``allow_redirects=True``, which only validated the initial caller-supplied URL host against the allowlist. An attacker could submit an allowed host that 302-redirected to an internal address (e.g. ``http://localhost:8000/api/...`` or a private RFC1918 range), and the backend would dutifully follow and proxy the response — a classic open-redirect-to-SSRF chain. With this loop, we re-run ``_cctv_host_allowed()`` on every hop's ``Location`` header. A redirect to a host that isn't on the allowlist is rejected with 502 rather than silently followed. """ import requests as _req from urllib.parse import urlparse, urljoin headers = _cctv_upstream_headers(request, profile) current_url = target_url hops = 0 try: while True: resp = _req.get( current_url, timeout=profile.timeout, stream=True, allow_redirects=False, headers=headers, ) # Redirect handling — re-validate the next-hop host before following. if resp.is_redirect or resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("Location", "") resp.close() if hops >= _CCTV_MAX_REDIRECTS: logger.warning( "CCTV upstream redirect chain exceeded limit [%s] %s", profile.name, target_url, ) raise HTTPException(status_code=502, detail="Upstream redirect chain too long") if not location: raise HTTPException(status_code=502, detail="Upstream redirect missing Location") next_url = urljoin(current_url, location) next_parsed = urlparse(next_url) if next_parsed.scheme not in ("http", "https"): raise HTTPException(status_code=502, detail="Upstream redirect to non-HTTP scheme") if not _cctv_host_allowed(next_parsed.hostname): logger.warning( "CCTV upstream redirect to disallowed host [%s] %s -> %s", profile.name, current_url, next_url, ) raise HTTPException(status_code=502, detail="Upstream redirect to disallowed host") current_url = next_url hops += 1 continue break except _req.exceptions.Timeout as exc: logger.warning("CCTV upstream timeout [%s] %s", profile.name, target_url) raise HTTPException(status_code=504, detail="Upstream timeout") from exc except _req.exceptions.RequestException as exc: logger.warning("CCTV upstream request failure [%s] %s: %s", profile.name, target_url, exc) raise HTTPException(status_code=502, detail="Upstream fetch failed") from exc if resp.status_code >= 400: logger.info("CCTV upstream HTTP %s [%s] %s", resp.status_code, profile.name, target_url) resp.close() raise HTTPException(status_code=int(resp.status_code), detail=f"Upstream returned {resp.status_code}") return resp def _rewrite_cctv_hls_playlist(base_url: str, body: str) -> str: import re from urllib.parse import urljoin, urlparse def _rewrite_target(target: str) -> str: candidate = str(target or "").strip() if not candidate or candidate.startswith("data:"): return candidate absolute = urljoin(base_url, candidate) parsed_target = urlparse(absolute) if parsed_target.scheme not in ("http", "https"): return candidate if not _cctv_host_allowed(parsed_target.hostname): return candidate return _proxied_cctv_url(absolute) rewritten_lines: list = [] for raw_line in body.splitlines(): stripped = raw_line.strip() if not stripped: rewritten_lines.append(raw_line) continue if stripped.startswith("#"): rewritten_lines.append(re.sub(r'URI="([^"]+)"', lambda match: f'URI="{_rewrite_target(match.group(1))}"', raw_line)) continue rewritten_lines.append(_rewrite_target(stripped)) return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "") def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str: from urllib.parse import urlparse clean_type = str(content_type or "").split(";", 1)[0].strip().lower() if clean_type and clean_type not in {"application/octet-stream", "binary/octet-stream"}: return content_type path = str(urlparse(target_url).path or "").lower() if path.endswith((".jpg", ".jpeg")): return "image/jpeg" if path.endswith(".png"): return "image/png" if path.endswith(".webp"): return "image/webp" if path.endswith(".gif"): return "image/gif" if path.endswith(".mp4"): return "video/mp4" if path.endswith((".m3u8", ".m3u")): return "application/vnd.apple.mpegurl" if path.endswith((".mjpg", ".mjpeg")): return "multipart/x-mixed-replace" return content_type or "application/octet-stream" def _proxy_cctv_media_response(request: Request, target_url: str): from urllib.parse import urlparse from fastapi.responses import Response parsed = urlparse(target_url) profile = _cctv_proxy_profile_for_url(target_url) resp = _fetch_cctv_upstream_response(request, target_url, profile) content_type = _infer_cctv_media_type_from_url( target_url, resp.headers.get("Content-Type", "application/octet-stream"), ) is_hls_playlist = ( ".m3u8" in str(parsed.path or "").lower() or "mpegurl" in content_type.lower() or "vnd.apple.mpegurl" in content_type.lower() ) if is_hls_playlist: body = resp.text if "#EXTM3U" in body: body = _rewrite_cctv_hls_playlist(target_url, body) resp.close() return Response(content=body, media_type=content_type, headers=_cctv_response_headers(resp, cache_seconds=profile.cache_seconds, include_length=False)) return StreamingResponse(resp.iter_content(chunk_size=65536), status_code=resp.status_code, media_type=content_type, headers=_cctv_response_headers(resp, cache_seconds=profile.cache_seconds), background=BackgroundTask(resp.close)) @router.get("/api/cctv/media") @limiter.limit("120/minute") async def cctv_media_proxy(request: Request, url: str = Query(...)): """Proxy CCTV media through the backend to bypass browser CORS restrictions.""" from urllib.parse import urlparse parsed = urlparse(url) if not _cctv_host_allowed(parsed.hostname): raise HTTPException(status_code=403, detail="Host not allowed") if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Invalid scheme") return _proxy_cctv_media_response(request, url)