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
Clients and provider keys
- Create or update client
+ Create or update client
Add provider key