From fd86ba268d2dca0b924505b06af283368f2bdad2 Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sat, 20 Jun 2026 21:45:23 -0700 Subject: [PATCH] fix(api): CORS headers on 5xx + 404 (not 500) for malformed cloud ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CORS on error responses. Starlette renders unhandled 500s in ServerErrorMiddleware, which sits OUTSIDE CORSMiddleware — so every gateway 5xx reached the browser with no Access-Control-* headers and looked like a CORS failure ("Load failed"), masking the real error platform-wide. Added an Exception (500) handler that returns a clean JSON 500 and re-attaches the allow-credentials CORS contract (echoes the request Origin iff allow-listed). Origins are now a single shared constant used by both CORSMiddleware and the handler. HTTPExceptions already get CORS (handled inside ExceptionMiddleware). 2. Malformed cloud id -> 404, not 500. GET /cloud/agents/{id} and /{id}/runs threw an unhandled asyncpg "invalid input syntax for type uuid" (e.g. the frontend sending "undefined") -> 500. _get_agent_or_404 now validates the id is a UUID and returns 404 agent_not_found; the run-by-id endpoints (status/logs/cancel) likewise guard run_id -> 404 run_not_found. Every agent-by-id endpoint already routes through _get_agent_or_404, so all are covered. Verified in isolation: a forced 500 returns Access-Control-Allow-Origin: https://wayforth.io; a bad id returns 404 (also with CORS). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/main.py | 57 ++++++++++++++++++++++++++++++++------- apps/api/routers/cloud.py | 20 ++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/apps/api/main.py b/apps/api/main.py index 45231c7..5b131cb 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -1251,23 +1251,46 @@ async def lifespan(app: FastAPI): # and drop the wildcard regex. Auth is primarily header-based # (X-Wayforth-API-Key), so cookie CSRF surface is limited, but tightening # this is defense in depth. +# Single source of truth for the allow-listed browser origins — used by both +# CORSMiddleware (the normal path) and the 500 handler below (the error path that +# bypasses CORSMiddleware). Keep them identical so error responses carry the same +# CORS contract as success responses. +_CORS_ALLOWED_ORIGINS = [ + "https://wayforth.io", + "https://www.wayforth.io", + "https://gateway.wayforth.io", + "https://mcp.wayforth.io", + "https://zeropointaccess.com", + "https://www.zeropointaccess.com", + "https://intent-exchange.lovable.app", +] app.add_middleware( CORSMiddleware, - allow_origins=[ - "https://wayforth.io", - "https://www.wayforth.io", - "https://gateway.wayforth.io", - "https://mcp.wayforth.io", - "https://zeropointaccess.com", - "https://www.zeropointaccess.com", - "https://intent-exchange.lovable.app", - ], + allow_origins=_CORS_ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "OPTIONS", "DELETE", "PUT"], allow_headers=["*"], ) +def _cors_error_headers(request: "Request") -> dict: + """CORS headers for error responses that bypass CORSMiddleware. + + Unhandled 500s are rendered by Starlette's ServerErrorMiddleware, which sits + OUTSIDE CORSMiddleware — so a raw 500 carries no Access-Control-* headers and + the browser surfaces a generic CORS/"Load failed" error that masks EVERY + gateway 5xx. Re-apply the same allow-credentials CORS contract here: echo the + request Origin iff it is allow-listed (never '*' with credentials).""" + origin = request.headers.get("origin", "") + if origin in _CORS_ALLOWED_ORIGINS: + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Vary": "Origin", + } + return {} + + # Request body size limits (defense-in-depth against memory exhaustion / JSON # bombs). Most endpoints take small JSON payloads — capped at 1 MB. /run and # /execute (including /execute/batch and /run/intents) carry larger payloads @@ -1538,6 +1561,22 @@ async def _payment_required_handler(request: Request, exc: HTTPException): app.add_exception_handler(HTTPException, _payment_required_handler) +# Unhandled-exception (500) handler. Starlette routes Exception/500 to +# ServerErrorMiddleware, which renders OUTSIDE CORSMiddleware — so without this, +# every 5xx reaches the browser with no Access-Control-* headers and looks like a +# CORS failure ("Load failed"), masking the real server error. We render a clean +# 500 and re-attach the CORS headers ourselves so the frontend can read it. +async def _internal_error_handler(request: Request, exc: Exception): + logger.exception("unhandled_error path=%s method=%s", request.url.path, request.method) + return JSONResponse( + status_code=500, + content={"detail": "internal_error"}, + headers=_cors_error_headers(request), + ) + +app.add_exception_handler(Exception, _internal_error_handler) + + # FINDING-010 cleanup (v0.8.5): the duplicate check_auth that lived here was # dead code — every route uses core.auth.check_auth — and carried the old # in-memory anonymous-counter bypass. Removed. diff --git a/apps/api/routers/cloud.py b/apps/api/routers/cloud.py index bd2754c..5b3c9b7 100644 --- a/apps/api/routers/cloud.py +++ b/apps/api/routers/cloud.py @@ -96,7 +96,21 @@ def _resolve_run_key(header_key: str, agent: dict) -> str: return "" +def _valid_uuid(value: str) -> bool: + try: + uuid.UUID(str(value)) + return True + except (ValueError, TypeError, AttributeError): + return False + + async def _get_agent_or_404(db, user_id: str, agent_id: str) -> dict: + # A malformed id (e.g. the frontend sending "undefined") can never identify an + # agent. Guard it as a clean 404 — passing it to a `$1::uuid` query otherwise + # raises asyncpg InvalidTextRepresentationError, which surfaced as an + # unhandled 500 (and, lacking CORS headers, looked like a CORS failure). + if not _valid_uuid(agent_id): + raise HTTPException(status_code=404, detail={"error": "agent_not_found"}) row = await db.fetchrow( "SELECT * FROM hosted_agents WHERE id = $1::uuid AND user_id = $2::uuid", agent_id, user_id, @@ -805,6 +819,8 @@ async def get_run( require_tier(tier, "cloud_agents") await _get_agent_or_404(db, user_id, agent_id) + if not _valid_uuid(run_id): + raise HTTPException(status_code=404, detail={"error": "run_not_found"}) row = await db.fetchrow(""" SELECT id, status, trigger, sandbox_id, started_at, completed_at, duration_ms, @@ -875,6 +891,8 @@ async def get_run_logs( require_tier(tier, "cloud_agents") await _get_agent_or_404(db, user_id, agent_id) + if not _valid_uuid(run_id): + raise HTTPException(status_code=404, detail={"error": "run_not_found"}) row = await db.fetchrow( "SELECT status, log_tail FROM agent_runs WHERE id = $1::uuid AND hosted_agent_id = $2::uuid", @@ -961,6 +979,8 @@ async def cancel_run( require_tier(tier, "cloud_agents") await _get_agent_or_404(db, user_id, agent_id) + if not _valid_uuid(run_id): + raise HTTPException(status_code=404, detail={"error": "run_not_found"}) row = await db.fetchrow( "SELECT status FROM agent_runs WHERE id = $1::uuid AND hosted_agent_id = $2::uuid",