From a07c178fdadb5c79f124563be741329c8da0c751 Mon Sep 17 00:00:00 2001 From: vasily Date: Fri, 16 Oct 2026 14:24:02 +0800 Subject: [PATCH] fix: require client secrets and sanitize uploaded images --- .env.example | 1 + PDR.md | 12 ++++----- README.md | 3 ++- app/main.py | 67 +++++++++++++++++++++++++++++++++++++++++------ deploy/nginx.conf | 2 +- docs/API.md | 16 +++++------ pyproject.toml | 1 + 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 869046f..4e7346d 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ ADMIN_SECRET=replace-with-a-long-random-secret DB_PATH=./data/proxy.db MAX_UPLOAD_BYTES=52428800 MAX_IMAGES=8 +MAX_IMAGE_PIXELS=25000000 HTTP_TIMEOUT_SECONDS=300 CONNECT_TIMEOUT_SECONDS=15 ALLOWED_ORIGINS= diff --git a/PDR.md b/PDR.md index 9a7ee08..18fe8da 100644 --- a/PDR.md +++ b/PDR.md @@ -10,7 +10,7 @@ Provide a small single-host service that routes image-generation requests from i - Photobooth/installation clients submit a prompt, model, optional parameters, and zero or more images. - An administrator provisions clients and manages access. -- A client may manage its own upstream keys when it has a client secret. +- Every client must have a secret. Generation, job polling, and client-managed key operations require that secret. - The initial service runs as one Uvicorn process against a local SQLite database on persistent disk. ## Requirements @@ -31,7 +31,7 @@ Provide a small single-host service that routes image-generation requests from i ### Non-functional and security - Use async HTTP for all upstream network I/O and a shared bounded connection pool. -- Bound aggregate uploaded bytes and image count. Allow only configured raster image MIME types. +- Bound aggregate uploaded bytes, image count, and decoded pixel dimensions. Accept only valid JPEG, PNG, and WebP images; re-encode before forwarding to strip metadata and trailing payloads. - Require an explicit administrator secret at startup; never ship a default secret. - Store client secrets hashed. Store provider keys only because they are needed for upstream requests; restrict database file access and redact keys from API responses. - Scope job reads and client key operations to the authenticated owning client. @@ -51,7 +51,7 @@ Provide a small single-host service that routes image-generation requests from i - `provider` (optional: `google` or `openrouter`) - `params` (optional JSON object string) - `images` (repeated image upload fields) -- `x-client-secret` header when a secret is configured for that client +- `x-client-secret` header (required) Success is `200` with `provider_used`, `model_used`, `images`, `text`, elapsed time, and failover attempt count. With `?async_mode=true`, success is `200` with `job_id`, status, and poll URL. @@ -69,7 +69,7 @@ Validation errors use FastAPI's `detail` format; upstream failures return `502` ## Data model -- `clients`: client ID primary key, name, optional client-secret hash, activation flag, creation timestamp. +- `clients`: client ID primary key, name, required client-secret hash, activation flag, creation timestamp. - `provider_keys`: owned by a client with cascading deletion; provider constrained to `google`/`openrouter`; key, priority, activation and failure metadata. - `jobs`: owned by a client with cascading deletion; status, serialized request/result/error and timestamps. On startup, interrupted jobs are marked failed. @@ -78,7 +78,7 @@ SQLite connections enable foreign keys and busy timeout; WAL is initialized once ## Architecture and flow 1. FastAPI validates the multipart request and client authentication. -2. Uploaded images are bounded and encoded for the provider's JSON API. +2. Uploaded images are bounded, decoded, re-encoded to an allowlisted raster format, then encoded for the provider's JSON API. 3. A route loads the active client's provider keys from SQLite. 4. The adapter translates the request to Google or OpenRouter format and sends through shared async `httpx`. 5. A retryable upstream failure advances to the next configured key; success is normalized into a provider-neutral response. @@ -88,7 +88,7 @@ SQLite connections enable foreign keys and busy timeout; WAL is initialized once 1. Install Python dependencies and set a unique `ADMIN_SECRET`. 2. Mount a persistent local volume at the configured database path with restrictive permissions. -3. Run one Uvicorn worker. Put TLS and request-size enforcement at Nginx; keep port 8000 private. +3. Run one Uvicorn worker as `www-data`, bound to `127.0.0.1:8081`. Put TLS and request-size enforcement at Nginx; keep the application port private. 4. Create clients, add provider credentials, then call `/healthz` and verify one small generation using the intended account/model. 5. Back up the database securely. Treat backups and job records as secrets and apply retention appropriate to the installation. diff --git a/README.md b/README.md index f5bf84a..0c3d141 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An asynchronous proxy for image generation through Google Gemini or OpenRouter. ## Start locally -Requirements: Python 3.12 or newer. +Requirements: Python 3.12 or newer. Uploaded images are decoded and re-encoded with Pillow; only JPEG, PNG, and WebP are accepted. ```sh python -m venv .venv @@ -63,6 +63,7 @@ The built-in `nano-banana` alias defaults to Gemini 2.5 Flash Image. Set `GOOGLE | `DB_PATH` | `./data/proxy.db` | SQLite database location. | | `MAX_UPLOAD_BYTES` | `52428800` | Aggregate raw image upload limit. | | `MAX_IMAGES` | `8` | Maximum reference image count. | +| `MAX_IMAGE_PIXELS` | `25000000` | Maximum width × height for each uploaded image. | | `HTTP_TIMEOUT_SECONDS` | `300` | Overall upstream request timeout. | | `CONNECT_TIMEOUT_SECONDS` | `15` | Upstream connect timeout. | | `GOOGLE_MODEL_NANO_BANANA` | `gemini-2.5-flash-image` | Native Google alias target. | diff --git a/app/main.py b/app/main.py index d88a3c4..e25094f 100644 --- a/app/main.py +++ b/app/main.py @@ -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():

Execution overview

By client

Recent generations

Clients and provider keys

-

Create or update client

+

Create or update client

Add provider key