commit c8ce8fc0851ed441edbcabb8a4674bd44e869142
parent 9afeb3f01d277d39ec93ff167f99150eaedb5e1b
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Sun, 4 Oct 2026 10:44:26 +0530
Merge pull request #28 from notamitgamer/Fix
Fix
Diffstat:
2 files changed, 157 insertions(+), 3 deletions(-)
diff --git a/app/main.py b/app/main.py
@@ -35,8 +35,10 @@ from .storage import (
)
from .gh_oidc import verify_actions_token
from .shortener import shorten_url, get_destination_url
+from .upload_guard import router as upload_guard_router, get_client_ip, inspect_upload, record_upload
app = FastAPI()
+app.include_router(upload_guard_router)
templates = Jinja2Templates(directory="app/templates")
STATIC_DIR = Path(__file__).parent / "static"
@@ -272,7 +274,7 @@ async def handle_upload(
folder: str = Form("uploads"),
):
folder = _validate_folder(folder)
- client_ip = request.client.host if request.client else "unknown"
+ client_ip = get_client_ip(request)
results = []
for file in files:
@@ -284,6 +286,7 @@ async def handle_upload(
size = os.path.getsize(temp_path)
try:
+ guard_ctx = await inspect_upload(request, file.filename, temp_path, size, folder, "file")
_upload_limiter.check_and_record(client_ip, size)
except HTTPException:
os.remove(temp_path)
@@ -295,6 +298,8 @@ async def handle_upload(
if os.path.exists(temp_path):
os.remove(temp_path)
+ await record_upload(guard_ctx, hf_path)
+
results.append({
"filename": file.filename,
"hf_path": hf_path,
@@ -365,7 +370,7 @@ async def gh_sync(
):
claims = verify_actions_token(token)
owner, repo = claims["owner"], claims["repo"]
- client_ip = request.client.host if request.client else "unknown"
+ client_ip = get_client_ip(request)
contents = await archive.read()
_gh_sync_limiter.check_and_record(
@@ -504,7 +509,7 @@ async def handle_upload_from_url(
body: UploadUrlRequest,
):
folder = _validate_folder(body.folder)
- client_ip = request.client.host if request.client else "unknown"
+ client_ip = get_client_ip(request)
url = body.url.strip()
_assert_public_url(url)
@@ -571,6 +576,14 @@ async def handle_upload_from_url(
raise
try:
+ guard_ctx = await inspect_upload(
+ request,
+ filename,
+ temp_path,
+ downloaded,
+ folder,
+ "url",
+ )
_upload_limiter.check_and_record(
client_ip,
downloaded,
@@ -589,6 +602,8 @@ async def handle_upload_from_url(
if os.path.exists(temp_path):
os.remove(temp_path)
+ await record_upload(guard_ctx, hf_path)
+
return {
"files": [{
"filename": filename,
diff --git a/app/upload_guard.py b/app/upload_guard.py
@@ -0,0 +1,139 @@
+"""Upload safety checks.
+
+Every upload endpoint calls inspect_upload() before the file is stored and
+record_upload() after it succeeds. Keeping the logic in this module means the
+rules can grow without touching the upload handlers in main.py.
+"""
+from __future__ import annotations
+
+import ipaddress
+import os
+from dataclasses import dataclass
+
+from fastapi import APIRouter, HTTPException, Request
+
+router = APIRouter()
+
+# Video containers that are refused outright, by extension.
+BLOCKED_VIDEO_EXTENSIONS = {
+ ".mp4", ".m4v", ".mov", ".webm", ".mkv", ".avi", ".wmv",
+ ".flv", ".mpg", ".mpeg", ".3gp", ".3g2", ".ogv",
+}
+
+# ISO base media files ("ftyp" box) are used for video AND for images/audio.
+# A file is only waved through when its major brand is one of these.
+_NON_VIDEO_FTYP_BRANDS = {
+ b"avif", b"avis", b"heic", b"heix", b"heim", b"heis",
+ b"mif1", b"msf1", b"M4A ", b"M4B ", b"M4P ", b"crx ",
+ b"jp2 ", b"jpx ", b"jpm ",
+}
+
+TRUST_CF_CONNECTING_IP = os.getenv("TRUST_CF_CONNECTING_IP", "1") == "1"
+TRUSTED_PROXY_HOPS = max(1, int(os.getenv("TRUSTED_PROXY_HOPS", "1")))
+
+
+def _valid_ip(value: str | None) -> str | None:
+ if not value:
+ return None
+ try:
+ return str(ipaddress.ip_address(value.strip()))
+ except ValueError:
+ return None
+
+
+def get_client_ip(request: Request) -> str:
+ """Best-effort real client IP behind Cloudflare / Render's proxy.
+
+ Order: CF-Connecting-IP, then X-Forwarded-For (counted from the right by
+ TRUSTED_PROXY_HOPS so a client cannot simply prepend a fake address), then
+ the socket peer.
+ """
+ if TRUST_CF_CONNECTING_IP:
+ ip = _valid_ip(request.headers.get("cf-connecting-ip"))
+ if ip:
+ return ip
+
+ xff = request.headers.get("x-forwarded-for")
+ if xff:
+ parts = [p.strip() for p in xff.split(",") if p.strip()]
+ if parts:
+ idx = len(parts) - TRUSTED_PROXY_HOPS
+ ip = _valid_ip(parts[idx] if idx >= 0 else parts[0])
+ if ip:
+ return ip
+
+ return _valid_ip(request.client.host if request.client else None) or "unknown"
+
+
+def _sniff_video(head: bytes) -> bool:
+ """True when the first bytes of a file look like a video container."""
+ if len(head) >= 12 and head[4:8] == b"ftyp":
+ return head[8:12] not in _NON_VIDEO_FTYP_BRANDS
+ if head.startswith(b"\x1a\x45\xdf\xa3"): # Matroska / WebM
+ return True
+ if head[:4] == b"RIFF" and head[8:12] == b"AVI ":
+ return True
+ if head.startswith(b"\x30\x26\xb2\x75\x8e\x66\xcf\x11"): # ASF / WMV
+ return True
+ if head.startswith(b"FLV\x01"):
+ return True
+ if head[:4] in (b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3"): # MPEG
+ return True
+ if head.startswith(b"OggS") and b"theora" in head[:64]:
+ return True
+ return False
+
+
+def is_video_upload(filename: str, temp_path: str) -> bool:
+ ext = os.path.splitext((filename or "").lower())[1]
+ if ext in BLOCKED_VIDEO_EXTENSIONS:
+ return True
+ try:
+ with open(temp_path, "rb") as f:
+ head = f.read(64)
+ except OSError:
+ return False
+ return _sniff_video(head)
+
+
+@dataclass
+class UploadContext:
+ ip: str
+ filename: str
+ size: int
+ folder: str
+ method: str # "file" or "url"
+ user_agent: str = ""
+
+
+async def inspect_upload(
+ request: Request,
+ filename: str,
+ temp_path: str,
+ size: int,
+ folder: str,
+ method: str = "file",
+) -> UploadContext:
+ """Run all upload checks. Raises HTTPException when the upload is refused."""
+ ctx = UploadContext(
+ ip=get_client_ip(request),
+ filename=filename or "",
+ size=size,
+ folder=folder,
+ method=method,
+ user_agent=request.headers.get("user-agent", "")[:300],
+ )
+
+ if is_video_upload(ctx.filename, temp_path):
+ raise HTTPException(
+ status_code=415,
+ detail="Video uploads (including .mp4) are not allowed on this CDN.",
+ )
+
+ return ctx
+
+
+async def record_upload(ctx: UploadContext, hf_path: str) -> None:
+ """Called after a successful upload. Logging is added in a later commit."""
+ return None
+