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

+1
View File
@@ -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=
+6 -6
View File
@@ -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.
+2 -1
View File
@@ -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. |
+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]));
+1 -1
View File
@@ -6,7 +6,7 @@ proxy_read_timeout 300s;
send_timeout 300s;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
+8 -8
View File
@@ -1,6 +1,6 @@
# phproxy API
Base URL on this machine's Tailscale network: `http://100.67.41.125:8080`
Production base URL: `https://ph_proxy.sokaris.link` (local development uses `http://127.0.0.1:8000`).
The service accepts `multipart/form-data` for generation. Management routes also accept form data. Responses are JSON unless noted. The examples use `curl`; an agent can translate them to Python `requests` or `httpx`.
@@ -11,13 +11,13 @@ There are two kinds of credentials:
- **Admin secret**: configured by the service operator. Send it as `x-admin-secret` to administrator endpoints, or `auth-secret` to provider-key endpoints.
- **Client secret**: configured when creating a client. Send it as `x-client-secret` when generating or polling. It can also be used as `auth-secret` for that client's provider-key endpoints.
For the current local development server, the temporary admin secret is `local-development-admin-secret`. Replace it before using the service with real API keys or outside a trusted development tailnet.
Set `ADMIN_SECRET` in the service environment. Do not commit it or include it in client scripts.
Create a client:
```sh
BASE_URL=http://100.67.41.125:8080
ADMIN_SECRET=local-development-admin-secret
BASE_URL=https://ph_proxy.sokaris.link
ADMIN_SECRET='value-from-the-service-environment'
curl -sS -X POST "$BASE_URL/api/admin/client" \
-H "x-admin-secret: $ADMIN_SECRET" \
@@ -58,7 +58,7 @@ The same endpoint may be authorized with the owning client's secret instead of t
| `params` | No | JSON object encoded as a string. Defaults to `{}`. |
| `images` | No | Repeat this multipart field for each reference image. |
Send `x-client-secret` if the client has a secret configured. The service accepts JPEG, PNG, WebP, and GIF images, up to 8 files and 50 MiB aggregate by default. Limits can be changed by the operator.
`x-client-secret` is required for every generation and job-poll request. Clients without a configured secret are rejected. Uploads accept JPEG, PNG, or WebP only (not GIF or SVG), up to 8 files and 50 MiB aggregate by default. The service decodes and re-encodes each image before forwarding it, stripping metadata and non-image payloads, and rejects malformed images, mismatched MIME types, and images larger than 25 megapixels by default. Operators can tune the limits.
```sh
curl -sS -X POST "$BASE_URL/v1/generate" \
@@ -150,7 +150,7 @@ Provider APIs may reject unsupported parameters or model IDs. Configure exact ID
| `GET /ui` | Network access control | Basic HTML administration page. |
| `GET /api/admin/state` | `x-admin-secret` | Lists clients and keys with only a short key preview. |
| `GET /api/admin/generations?limit=100` | `x-admin-secret` | Returns aggregate totals, per-client totals, and recent generation timing/cost records (limit 1–200). |
| `POST /api/admin/client` | `x-admin-secret` | Form fields: `client_id`, `name`, optional `client_secret`; creates or updates a client. |
| `POST /api/admin/client` | `x-admin-secret` | Form fields: `client_id`, `name`, and required `client_secret` (16–256 characters); creates or updates a client. |
| `POST /api/keys` | `auth-secret` | Form fields: `client_id`, `provider`, `api_key`, optional `priority`. Admin or owning client may add keys. |
| `DELETE /api/keys/{key_id}` | `auth-secret` | Deletes a key owned by the authenticated admin/client. |
@@ -162,10 +162,10 @@ Validation and authorization failures use FastAPI's `{"detail":"..."}` response
| --- | --- |
| `400` | Invalid parameters, provider, or missing provider keys. |
| `401` | Missing or invalid admin/client secret. |
| `403` | Client is unknown or inactive. |
| `403` | Client is unknown, inactive, or has no secret configured. |
| `404` | Client/key/job was not found, or the job belongs to another client. |
| `413` | Request or image upload exceeds configured limits. |
| `415` | Unsupported image MIME type. |
| `415` | Unsupported, malformed, or MIME-mismatched image. |
| `502` | Upstream failure after applicable failover attempts. Response contains status metadata, not upstream response bodies. |
## Notes for test scripts
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"aiosqlite>=0.20,<1",
"uvicorn[standard]>=0.30,<1",
"python-multipart>=0.0.9,<1",
"Pillow>=10.4,<13",
]
[tool.setuptools.packages.find]