fix: require client secrets and sanitize uploaded images
This commit is contained in:
1 parent
1f43b2d065
commit
a07c178fda
7 files changed
+78
-24
No files matched your search
+59
-8
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
Reference in new issue
Block a user