Deploy DataForge playground API
Browse files- dataforge/release/playground_check.py +22 -5
- playground/api/app.py +57 -3
dataforge/release/playground_check.py
CHANGED
|
@@ -204,6 +204,12 @@ def _check_cors(
|
|
| 204 |
headers={"Origin": origin},
|
| 205 |
)
|
| 206 |
negative, negative_latency_ms = _timed_request(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
client,
|
| 208 |
"OPTIONS",
|
| 209 |
f"{backend_url}/api/health",
|
|
@@ -213,12 +219,19 @@ def _check_cors(
|
|
| 213 |
},
|
| 214 |
)
|
| 215 |
allowed_origin = positive.headers.get("access-control-allow-origin")
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
detail = (
|
| 219 |
-
"
|
| 220 |
if ok
|
| 221 |
-
else "CORS
|
| 222 |
)
|
| 223 |
return PlaygroundCheck(
|
| 224 |
"cors_correct",
|
|
@@ -229,9 +242,13 @@ def _check_cors(
|
|
| 229 |
"positive_status_code": positive.status_code,
|
| 230 |
"negative_status_code": negative.status_code,
|
| 231 |
"allowed_origin": allowed_origin,
|
| 232 |
-
"negative_allowed_origin":
|
|
|
|
|
|
|
|
|
|
| 233 |
"positive_latency_ms": round(positive_latency_ms, 2),
|
| 234 |
"negative_latency_ms": round(negative_latency_ms, 2),
|
|
|
|
| 235 |
},
|
| 236 |
)
|
| 237 |
except Exception as exc:
|
|
|
|
| 204 |
headers={"Origin": origin},
|
| 205 |
)
|
| 206 |
negative, negative_latency_ms = _timed_request(
|
| 207 |
+
client,
|
| 208 |
+
"GET",
|
| 209 |
+
f"{backend_url}/api/health",
|
| 210 |
+
headers={"Origin": NEGATIVE_CORS_ORIGIN},
|
| 211 |
+
)
|
| 212 |
+
preflight, preflight_latency_ms = _timed_request(
|
| 213 |
client,
|
| 214 |
"OPTIONS",
|
| 215 |
f"{backend_url}/api/health",
|
|
|
|
| 219 |
},
|
| 220 |
)
|
| 221 |
allowed_origin = positive.headers.get("access-control-allow-origin")
|
| 222 |
+
negative_allowed_origin = negative.headers.get("access-control-allow-origin")
|
| 223 |
+
preflight_allowed_origin = preflight.headers.get("access-control-allow-origin")
|
| 224 |
+
negative_error = ""
|
| 225 |
+
try:
|
| 226 |
+
negative_error = str(negative.json().get("error", ""))
|
| 227 |
+
except ValueError:
|
| 228 |
+
negative_error = ""
|
| 229 |
+
negative_denied = negative.status_code == 403 and negative_error == "origin_not_allowed"
|
| 230 |
+
ok = allowed_origin == origin and positive.status_code == 200 and negative_denied
|
| 231 |
detail = (
|
| 232 |
+
"Configured origin is allowed and disallowed origins cannot read API data."
|
| 233 |
if ok
|
| 234 |
+
else "CORS origin enforcement is incorrect."
|
| 235 |
)
|
| 236 |
return PlaygroundCheck(
|
| 237 |
"cors_correct",
|
|
|
|
| 242 |
"positive_status_code": positive.status_code,
|
| 243 |
"negative_status_code": negative.status_code,
|
| 244 |
"allowed_origin": allowed_origin,
|
| 245 |
+
"negative_allowed_origin": negative_allowed_origin,
|
| 246 |
+
"negative_error": negative_error,
|
| 247 |
+
"negative_preflight_status_code": preflight.status_code,
|
| 248 |
+
"negative_preflight_allowed_origin": preflight_allowed_origin,
|
| 249 |
"positive_latency_ms": round(positive_latency_ms, 2),
|
| 250 |
"negative_latency_ms": round(negative_latency_ms, 2),
|
| 251 |
+
"negative_preflight_latency_ms": round(preflight_latency_ms, 2),
|
| 252 |
},
|
| 253 |
)
|
| 254 |
except Exception as exc:
|
playground/api/app.py
CHANGED
|
@@ -13,6 +13,7 @@ import asyncio
|
|
| 13 |
import io
|
| 14 |
import logging
|
| 15 |
import os
|
|
|
|
| 16 |
import tempfile
|
| 17 |
import time
|
| 18 |
import uuid
|
|
@@ -355,6 +356,50 @@ class FallbackRateLimitMiddleware(BaseHTTPMiddleware):
|
|
| 355 |
return await call_next(request)
|
| 356 |
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
if _SlowapiLimiter is not None:
|
| 359 |
limiter: _LimiterLike = cast(
|
| 360 |
_LimiterLike,
|
|
@@ -385,6 +430,10 @@ def _build_cors_origin_regex() -> str | None:
|
|
| 385 |
return "^(" + "|".join(patterns) + ")$"
|
| 386 |
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
app = FastAPI(
|
| 389 |
title="DataForge Playground API",
|
| 390 |
description="Stateless backend for the hosted DataForge playground.",
|
|
@@ -401,12 +450,17 @@ if not SLOWAPI_AVAILABLE:
|
|
| 401 |
app.add_middleware(FallbackRateLimitMiddleware)
|
| 402 |
app.add_middleware(
|
| 403 |
CORSMiddleware,
|
| 404 |
-
allow_origins=
|
| 405 |
-
allow_origin_regex=
|
| 406 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 407 |
allow_headers=["*"],
|
| 408 |
allow_credentials=False,
|
| 409 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
app.add_middleware(RequestContextMiddleware)
|
| 411 |
app.state.limiter = limiter
|
| 412 |
app.add_exception_handler(HTTPException, problem_exception_handler)
|
|
@@ -747,7 +801,7 @@ async def health() -> dict[str, Any]:
|
|
| 747 |
"server_time_utc": datetime.now(UTC).isoformat(),
|
| 748 |
"environment": _environment_name(),
|
| 749 |
"limits": _limits_payload(),
|
| 750 |
-
"cors_configured": bool(
|
| 751 |
"otel_enabled": os.environ.get("DATAFORGE_OTEL_ENABLED", "").strip().lower()
|
| 752 |
in OTEL_ENABLED_VALUES,
|
| 753 |
"otel_instrumented": OTEL_INSTRUMENTED,
|
|
|
|
| 13 |
import io
|
| 14 |
import logging
|
| 15 |
import os
|
| 16 |
+
import re
|
| 17 |
import tempfile
|
| 18 |
import time
|
| 19 |
import uuid
|
|
|
|
| 356 |
return await call_next(request)
|
| 357 |
|
| 358 |
|
| 359 |
+
class OriginGuardMiddleware(BaseHTTPMiddleware):
|
| 360 |
+
"""Reject browser requests from origins outside the configured allowlist."""
|
| 361 |
+
|
| 362 |
+
def __init__(
|
| 363 |
+
self,
|
| 364 |
+
app: ASGIApp,
|
| 365 |
+
*,
|
| 366 |
+
allow_origins: list[str],
|
| 367 |
+
allow_origin_regex: str | None,
|
| 368 |
+
) -> None:
|
| 369 |
+
super().__init__(app)
|
| 370 |
+
self._allow_origins = frozenset(allow_origins)
|
| 371 |
+
self._allow_origin_pattern = (
|
| 372 |
+
re.compile(allow_origin_regex) if allow_origin_regex is not None else None
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
def _allowed(self, origin: str) -> bool:
|
| 376 |
+
if origin in self._allow_origins:
|
| 377 |
+
return True
|
| 378 |
+
return bool(
|
| 379 |
+
self._allow_origin_pattern is not None
|
| 380 |
+
and self._allow_origin_pattern.fullmatch(origin)
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
async def dispatch(
|
| 384 |
+
self,
|
| 385 |
+
request: Request,
|
| 386 |
+
call_next: RequestResponseEndpoint,
|
| 387 |
+
) -> Response:
|
| 388 |
+
"""Deny disallowed browser origins before endpoint handlers run."""
|
| 389 |
+
origin = request.headers.get("origin")
|
| 390 |
+
if origin and not self._allowed(origin):
|
| 391 |
+
return problem_response(
|
| 392 |
+
status=403,
|
| 393 |
+
type_="https://dataforge.local/problems/origin_not_allowed",
|
| 394 |
+
title="Origin Not Allowed",
|
| 395 |
+
detail="This playground backend only accepts browser requests from configured frontend origins.",
|
| 396 |
+
instance=str(request.url.path),
|
| 397 |
+
error="origin_not_allowed",
|
| 398 |
+
request_id=_request_id(request),
|
| 399 |
+
)
|
| 400 |
+
return await call_next(request)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
if _SlowapiLimiter is not None:
|
| 404 |
limiter: _LimiterLike = cast(
|
| 405 |
_LimiterLike,
|
|
|
|
| 430 |
return "^(" + "|".join(patterns) + ")$"
|
| 431 |
|
| 432 |
|
| 433 |
+
CORS_ORIGINS = _build_cors_origins()
|
| 434 |
+
CORS_ORIGIN_REGEX = _build_cors_origin_regex()
|
| 435 |
+
|
| 436 |
+
|
| 437 |
app = FastAPI(
|
| 438 |
title="DataForge Playground API",
|
| 439 |
description="Stateless backend for the hosted DataForge playground.",
|
|
|
|
| 450 |
app.add_middleware(FallbackRateLimitMiddleware)
|
| 451 |
app.add_middleware(
|
| 452 |
CORSMiddleware,
|
| 453 |
+
allow_origins=CORS_ORIGINS,
|
| 454 |
+
allow_origin_regex=CORS_ORIGIN_REGEX,
|
| 455 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 456 |
allow_headers=["*"],
|
| 457 |
allow_credentials=False,
|
| 458 |
)
|
| 459 |
+
app.add_middleware(
|
| 460 |
+
OriginGuardMiddleware,
|
| 461 |
+
allow_origins=CORS_ORIGINS,
|
| 462 |
+
allow_origin_regex=CORS_ORIGIN_REGEX,
|
| 463 |
+
)
|
| 464 |
app.add_middleware(RequestContextMiddleware)
|
| 465 |
app.state.limiter = limiter
|
| 466 |
app.add_exception_handler(HTTPException, problem_exception_handler)
|
|
|
|
| 801 |
"server_time_utc": datetime.now(UTC).isoformat(),
|
| 802 |
"environment": _environment_name(),
|
| 803 |
"limits": _limits_payload(),
|
| 804 |
+
"cors_configured": bool(CORS_ORIGINS or CORS_ORIGIN_REGEX),
|
| 805 |
"otel_enabled": os.environ.get("DATAFORGE_OTEL_ENABLED", "").strip().lower()
|
| 806 |
in OTEL_ENABLED_VALUES,
|
| 807 |
"otel_instrumented": OTEL_INSTRUMENTED,
|