cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / upload_guard.py

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