fix: require client secrets and sanitize uploaded images

This commit is contained in:
vasily committed 2026-10-16 14:24:02 +08:00
1 parent 1f43b2d065
commit a07c178fda
7 files changed
+78 -24

No files matched your search

+59 -8
View File
@@ -4,6 +4,7 @@ import asyncio
import base64
import hashlib
import hmac
import io
import json
import logging
import os
@@ -16,12 +17,14 @@ import aiosqlite
import httpx
from fastapi import FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse
from PIL import Image, ImageOps, UnidentifiedImageError
LOG = logging.getLogger("phproxy")
DB_PATH = os.getenv("DB_PATH", "./data/proxy.db")
ADMIN_SECRET = os.getenv("ADMIN_SECRET", "")
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024)))
MAX_IMAGES = int(os.getenv("MAX_IMAGES", "8"))
MAX_IMAGE_PIXELS = int(os.getenv("MAX_IMAGE_PIXELS", "25000000"))
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "300"))
CONNECT_TIMEOUT_SECONDS = float(os.getenv("CONNECT_TIMEOUT_SECONDS", "15"))
MODEL_MAP = {
@@ -30,7 +33,9 @@ MODEL_MAP = {
"openrouter": os.getenv("OPENROUTER_MODEL_NANO_BANANA", "google/gemini-2.5-flash-image"),
},
}
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
IMAGE_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
IMAGE_FORMAT_TYPES = {mime: image_format for image_format, mime in IMAGE_FORMATS.items()}
image_validation_semaphore = asyncio.Semaphore(1)
http_client: httpx.AsyncClient | None = None
@@ -217,10 +222,48 @@ async def authorize_client(client_id: str, secret: str | None) -> None:
row = await cur.fetchone()
if not row or not row["is_active"]:
raise HTTPException(status_code=403, detail="Invalid or inactive client")
if row["secret_hash"] and not secret_matches(secret, row["secret_hash"]):
if not row["secret_hash"]:
raise HTTPException(status_code=403, detail="Client secret must be configured")
if not secret_matches(secret, row["secret_hash"]):
raise HTTPException(status_code=401, detail="Invalid client secret")
def sanitize_image(raw: bytes, declared_mime: str) -> tuple[bytes, str]:
"""Decode and re-encode accepted raster formats, stripping appended payloads/metadata."""
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
try:
with Image.open(io.BytesIO(raw)) as source:
image_format = source.format
actual_mime = IMAGE_FORMATS.get(image_format or "")
if actual_mime is None or actual_mime != declared_mime:
raise HTTPException(status_code=415, detail="File content does not match an allowed image type")
width, height = source.size
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
raise HTTPException(status_code=413, detail="Image dimensions exceed configured limits")
source.load()
image = ImageOps.exif_transpose(source)
output = io.BytesIO()
if image_format == "JPEG":
if image.mode not in ("L", "RGB"):
image = image.convert("RGB")
image.save(output, format="JPEG", quality=95)
elif image_format == "PNG":
if image.mode not in ("1", "L", "LA", "P", "RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
image.save(output, format="PNG")
else:
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
image.save(output, format="WEBP", quality=95)
return output.getvalue(), actual_mime
except HTTPException:
raise
except (Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
raise HTTPException(status_code=413, detail="Image dimensions exceed configured limits") from exc
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise HTTPException(status_code=415, detail="Uploaded file is not a valid supported image") from exc
async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], int]:
if len(images) > MAX_IMAGES:
raise HTTPException(status_code=413, detail=f"At most {MAX_IMAGES} images are allowed")
@@ -228,7 +271,7 @@ async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], i
encoded: list[dict[str, str]] = []
for image in images:
mime = (image.content_type or "").lower()
if mime not in ALLOWED_IMAGE_TYPES:
if mime not in IMAGE_FORMAT_TYPES:
raise HTTPException(status_code=415, detail=f"Unsupported image type: {mime or 'unknown'}")
chunks: list[bytes] = []
while chunk := await image.read(1024 * 1024):
@@ -237,8 +280,14 @@ async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], i
raise HTTPException(status_code=413, detail="Uploaded images exceed configured limit")
chunks.append(chunk)
raw = b"".join(chunks)
if raw:
encoded.append({"mime_type": mime, "b64": base64.b64encode(raw).decode("ascii")})
if not raw:
raise HTTPException(status_code=415, detail="Uploaded image is empty")
async with image_validation_semaphore:
sanitized, actual_mime = await asyncio.to_thread(sanitize_image, raw, mime)
if total - len(raw) + len(sanitized) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Validated images exceed configured upload limit")
total += len(sanitized) - len(raw)
encoded.append({"mime_type": actual_mime, "b64": base64.b64encode(sanitized).decode("ascii")})
await image.close()
return encoded, total
@@ -633,11 +682,13 @@ async def admin_generations(limit: int = Query(50, ge=1, le=200),
@app.post("/api/admin/client")
async def add_client(client_id: str = Form(...), name: str = Form(...),
client_secret: str = Form(""), x_admin_secret: str | None = Header(None)):
client_secret: str = Form(...), x_admin_secret: str | None = Header(None)):
auth_admin(x_admin_secret)
if not client_id.strip() or len(client_id) > 100 or not name.strip():
raise HTTPException(status_code=400, detail="client_id and name are required")
hashed = secret_hash(client_secret) if client_secret else None
if len(client_secret) < 16 or len(client_secret) > 256:
raise HTTPException(status_code=400, detail="client_secret must be 16 to 256 characters")
hashed = secret_hash(client_secret)
async with db() as conn:
await conn.execute("INSERT INTO clients(client_id,name,secret_hash,created_at) VALUES(?,?,?,?) "
"ON CONFLICT(client_id) DO UPDATE SET name=excluded.name,secret_hash=excluded.secret_hash",
@@ -705,7 +756,7 @@ async def portal_ui():
<section class=panel><h2>Execution overview</h2><div id=summary class=cards></div><h3>By client</h3><div id=by-client></div></section>
<section class=panel><h2>Recent generations</h2><div class=table-wrap id=history></div></section>
<section class=panel><h2>Clients and provider keys</h2><pre id=o></pre></section>
<section class=panel><h2>Create or update client</h2><form id=c><input name=client_id placeholder='Client ID' required><input name=name placeholder=Name required><input name=client_secret placeholder='Optional client secret'><button>Create client</button></form>
<section class=panel><h2>Create or update client</h2><form id=c><input name=client_id placeholder='Client ID' required><input name=name placeholder=Name required><input name=client_secret placeholder='Client secret (16+ characters)' minlength=16 maxlength=256 required><button>Create or update client</button></form>
<h2>Add provider key</h2><form id=k><input name=client_id placeholder='Client ID' required><select name=provider><option>google</option><option>openrouter</option></select><input name=api_key placeholder='Provider API key' required><input name=priority type=number value=1 min=1><button>Add key</button></form></section>
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));