upload_guard.py (7532B)
1 """Upload safety checks. 2 3 Every upload endpoint calls inspect_upload() before the file is stored. 4 Keeping the logic in this module means the rules can grow without touching 5 the upload handlers in main.py. 6 """ 7 from __future__ import annotations 8 9 import httpx 10 import ipaddress 11 import os 12 import time 13 from dataclasses import dataclass 14 15 from fastapi import HTTPException, Request 16 17 from .admin import is_banned, record_blocked 18 19 20 # Video containers that are refused outright, by extension. 21 BLOCKED_VIDEO_EXTENSIONS = { 22 ".mp4", ".m4v", ".mov", ".webm", ".mkv", ".avi", ".wmv", 23 ".flv", ".mpg", ".mpeg", ".3gp", ".3g2", ".ogv", 24 } 25 26 # ISO base media files ("ftyp" box) are used for video AND for images/audio. 27 # A file is only waved through when its major brand is one of these. 28 _NON_VIDEO_FTYP_BRANDS = { 29 b"avif", b"avis", b"heic", b"heix", b"heim", b"heis", 30 b"mif1", b"msf1", b"M4A ", b"M4B ", b"M4P ", b"crx ", 31 b"jp2 ", b"jpx ", b"jpm ", 32 } 33 34 VPN_BLOCK = os.getenv("VPN_BLOCK", "1") == "1" 35 VPN_FAIL_OPEN = os.getenv("VPN_FAIL_OPEN", "1") == "1" # allow upload if lookup fails 36 PROXYCHECK_KEY = os.getenv("PROXYCHECK_KEY", "") 37 VPN_ALLOWLIST = {i.strip() for i in os.getenv("VPN_ALLOWLIST", "").split(",") if i.strip()} 38 _vpn_cache: dict[str, tuple[float, bool | None]] = {} 39 _VPN_TTL = 6 * 3600 40 TRUST_CF_CONNECTING_IP = os.getenv("TRUST_CF_CONNECTING_IP", "0") == "1" 41 TRUSTED_PROXY_HOPS = max(1, int(os.getenv("TRUSTED_PROXY_HOPS", "1"))) 42 43 44 def _valid_ip(value: str | None) -> str | None: 45 if not value: 46 return None 47 try: 48 return str(ipaddress.ip_address(value.strip())) 49 except ValueError: 50 return None 51 52 53 # Cloudflare's published edge ranges (https://www.cloudflare.com/ips/). A request that 54 # really arrived through Cloudflare carries the visitor's address in CF-Connecting-IP. 55 _CLOUDFLARE_NETS = [ipaddress.ip_network(n) for n in ( 56 "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "104.16.0.0/13", "104.24.0.0/14", 57 "108.162.192.0/18", "131.0.72.0/22", "141.101.64.0/18", "162.158.0.0/15", "172.64.0.0/13", 58 "173.245.48.0/20", "188.114.96.0/20", "190.93.240.0/20", "197.234.240.0/22", "198.41.128.0/17", 59 "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", 60 "2a06:98c0::/29", "2c0f:f248::/32", 61 )] 62 63 64 def is_cloudflare_ip(value: str) -> bool: 65 try: 66 ip = ipaddress.ip_address(value) 67 except ValueError: 68 return False 69 return any(ip.version == n.version and ip in n for n in _CLOUDFLARE_NETS) 70 71 72 def is_public_ip(value: str) -> bool: 73 try: 74 return ipaddress.ip_address(value).is_global 75 except ValueError: 76 return False 77 78 79 def get_client_ip(request: Request) -> str: 80 """Best-effort real client IP behind Cloudflare / Render's proxy. 81 82 Order: CF-Connecting-IP, then X-Forwarded-For (counted from the right by 83 TRUSTED_PROXY_HOPS so a client cannot simply prepend a fake address), then 84 the socket peer. 85 """ 86 if TRUST_CF_CONNECTING_IP: 87 ip = _valid_ip(request.headers.get("cf-connecting-ip")) 88 if ip: 89 return ip 90 91 xff = request.headers.get("x-forwarded-for") 92 if xff: 93 parts = [p.strip() for p in xff.split(",") if p.strip()] 94 # Hosts like Render append their own internal (10.x etc.) proxy addresses to 95 # the right-hand end. Those are never the visitor, so drop them before counting hops. 96 public_parts = list(parts) 97 while public_parts and not is_public_ip(public_parts[-1]): 98 public_parts.pop() 99 parts = public_parts or parts 100 # The last remaining entry is the address that actually connected to the platform 101 # (appended by it, so the visitor can't forge it). If that is a Cloudflare edge, the 102 # request really came through Cloudflare and CF-Connecting-IP is genuine. 103 if parts and is_cloudflare_ip(parts[-1]): 104 ip = _valid_ip(request.headers.get("cf-connecting-ip")) 105 if ip: 106 return ip 107 if parts: 108 idx = len(parts) - TRUSTED_PROXY_HOPS 109 ip = _valid_ip(parts[idx] if idx >= 0 else parts[0]) 110 if ip: 111 return ip 112 113 return _valid_ip(request.client.host if request.client else None) or "unknown" 114 115 116 def _sniff_video(head: bytes) -> bool: 117 """True when the first bytes of a file look like a video container.""" 118 if len(head) >= 12 and head[4:8] == b"ftyp": 119 return head[8:12] not in _NON_VIDEO_FTYP_BRANDS 120 if head.startswith(b"\x1a\x45\xdf\xa3"): # Matroska / WebM 121 return True 122 if head[:4] == b"RIFF" and head[8:12] == b"AVI ": 123 return True 124 if head.startswith(b"\x30\x26\xb2\x75\x8e\x66\xcf\x11"): # ASF / WMV 125 return True 126 if head.startswith(b"FLV\x01"): 127 return True 128 if head[:4] in (b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3"): # MPEG 129 return True 130 if head.startswith(b"OggS") and b"theora" in head[:64]: 131 return True 132 return False 133 134 135 def is_video_upload(filename: str, temp_path: str) -> bool: 136 ext = os.path.splitext((filename or "").lower())[1] 137 if ext in BLOCKED_VIDEO_EXTENSIONS: 138 return True 139 try: 140 with open(temp_path, "rb") as f: 141 head = f.read(64) 142 except OSError: 143 return False 144 return _sniff_video(head) 145 146 147 @dataclass 148 class UploadContext: 149 ip: str 150 filename: str 151 size: int 152 folder: str 153 method: str # "file" or "url" 154 user_agent: str = "" 155 156 157 async def inspect_upload( 158 request: Request, 159 filename: str, 160 temp_path: str, 161 size: int, 162 folder: str, 163 method: str = "file", 164 ) -> UploadContext: 165 """Run all upload checks. Raises HTTPException when the upload is refused.""" 166 ctx = UploadContext( 167 ip=get_client_ip(request), 168 filename=filename or "", 169 size=size, 170 folder=folder, 171 method=method, 172 user_agent=request.headers.get("user-agent", "")[:300], 173 ) 174 175 if await is_banned(ctx.ip): 176 await record_blocked(ctx, "banned") 177 raise HTTPException(status_code=403, detail="You are banned from uploading to this CDN.") 178 179 if VPN_BLOCK and ctx.ip not in VPN_ALLOWLIST: 180 v = await is_vpn(ctx.ip) 181 if v or (v is None and not VPN_FAIL_OPEN): 182 await record_blocked(ctx, "blocked_vpn") 183 raise HTTPException(status_code=403, detail="Uploads from VPNs/proxies are not allowed.") 184 185 if is_video_upload(ctx.filename, temp_path): 186 await record_blocked(ctx, "blocked_video") 187 raise HTTPException( 188 status_code=415, 189 detail="Video uploads (including .mp4) are not allowed on this CDN.", 190 ) 191 192 return ctx 193 194 195 async def is_vpn(ip: str) -> bool | None: 196 """True = VPN/proxy/hosting, False = clean, None = lookup failed.""" 197 try: 198 if ipaddress.ip_address(ip).is_private: 199 return False 200 except ValueError: 201 return None 202 hit = _vpn_cache.get(ip) 203 if hit and hit[0] > time.time(): 204 return hit[1] 205 verdict: bool | None = None 206 try: 207 async with httpx.AsyncClient(timeout=5.0) as c: 208 r = await c.get(f"https://proxycheck.io/v2/{ip}", params={"vpn": 1, "key": PROXYCHECK_KEY}) 209 info = r.json().get(ip, {}) 210 if "proxy" in info: 211 verdict = info["proxy"] == "yes" 212 except Exception: 213 verdict = None 214 if len(_vpn_cache) > 5000: 215 _vpn_cache.clear() 216 _vpn_cache[ip] = (time.time() + (_VPN_TTL if verdict is not None else 300), verdict) 217 return verdict