1. Image upload size limit — _MAX_IMAGE_BYTES = 10 MB in both serve/server.py and edit/server.py
2. Image MIME type whitelist — _ALLOWED_IMAGE_TYPES blocks SVG XSS in both servers 3. Filename collision safety — _unique_image_name() helper in both servers 4. OAuth CSRF — state token generated in edit/server.py auth-url, stored in _oauth_states, validated and discarded in callback; strava_api.auth_url() accepts optional state param 5. Error message leak — upload processing errors now return generic "Processing failed" instead of exception type/message 6. Handle injection in subprocess — _trigger_rebuild now asserts handle matches _VALID_HANDLE before passing to subprocess
This commit is contained in:
+34
-9
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -20,6 +21,9 @@ site_url: str = "http://localhost:4321"
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
|
||||
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
|
||||
_oauth_states: set[str] = set()
|
||||
|
||||
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
@@ -38,6 +42,21 @@ def _check_id(activity_id: str) -> str:
|
||||
return activity_id
|
||||
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
# ── HTML UI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_HTML = """\
|
||||
@@ -419,14 +438,15 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe
|
||||
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = Path(file.filename).name
|
||||
# Only allow image content types
|
||||
ct = file.content_type or ""
|
||||
if not ct.startswith("image/"):
|
||||
raise HTTPException(400, f"Only image files are accepted (got {ct})")
|
||||
dest = images_dir / safe_name
|
||||
dest.write_bytes(await file.read())
|
||||
return JSONResponse({"ok": True, "filename": dest.name})
|
||||
if ct not in _ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
|
||||
contents = await file.read()
|
||||
if len(contents) > _MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024 * 1024)} MB)")
|
||||
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
return JSONResponse({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.get("/api/athlete")
|
||||
@@ -678,16 +698,21 @@ async def strava_auth_url(request: Request) -> JSONResponse:
|
||||
"""Return the Strava OAuth URL the browser should open."""
|
||||
if not strava_client_id:
|
||||
raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.")
|
||||
state = secrets.token_urlsafe(16)
|
||||
_oauth_states.add(state)
|
||||
redirect_uri = str(request.url_for("strava_callback"))
|
||||
from bincio.extract.strava_api import auth_url
|
||||
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri)})
|
||||
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)})
|
||||
|
||||
|
||||
@app.get("/api/strava/callback", name="strava_callback")
|
||||
async def strava_callback(code: str = "", error: str = "") -> RedirectResponse:
|
||||
async def strava_callback(code: str = "", error: str = "", state: str = "") -> RedirectResponse:
|
||||
"""Strava OAuth callback — exchange code for token then redirect to the site."""
|
||||
if error or not code:
|
||||
return RedirectResponse(f"{site_url}?strava=error")
|
||||
if state not in _oauth_states:
|
||||
return RedirectResponse(f"{site_url}?strava=error")
|
||||
_oauth_states.discard(state)
|
||||
if not strava_client_id or not strava_client_secret:
|
||||
return RedirectResponse(f"{site_url}?strava=error")
|
||||
dd = _get_data_dir()
|
||||
|
||||
Reference in New Issue
Block a user