feat(SEC-001): protect workspace bootstrap with turnstile

This commit is contained in:
Bu5hm4nn
2026-03-25 10:02:10 +01:00
parent f6667b6b63
commit 40f7e74a1b
15 changed files with 323 additions and 34 deletions

View File

@@ -10,14 +10,15 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.responses import RedirectResponse, Response
from nicegui import ui # type: ignore[attr-defined]
import app.pages # noqa: F401
from app.api.routes import router as api_router
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.services import turnstile as turnstile_service
from app.services.cache import CacheService
from app.services.data_service import DataService
from app.services.runtime import set_data_service
@@ -37,11 +38,14 @@ class Settings:
websocket_interval_seconds: int = 5
nicegui_mount_path: str = "/"
nicegui_storage_secret: str = "vault-dash-dev-secret"
turnstile_site_key: str = ""
turnstile_secret_key: str = ""
@classmethod
def load(cls) -> Settings:
cls._load_dotenv()
origins = os.getenv("CORS_ORIGINS", "*")
turnstile = turnstile_service.load_turnstile_settings()
return cls(
app_name=os.getenv("APP_NAME", cls.app_name),
environment=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", cls.environment)),
@@ -52,6 +56,8 @@ class Settings:
websocket_interval_seconds=int(os.getenv("WEBSOCKET_INTERVAL_SECONDS", cls.websocket_interval_seconds)),
nicegui_mount_path=os.getenv("NICEGUI_MOUNT_PATH", cls.nicegui_mount_path),
nicegui_storage_secret=os.getenv("NICEGUI_STORAGE_SECRET", cls.nicegui_storage_secret),
turnstile_site_key=turnstile.site_key,
turnstile_secret_key=turnstile.secret_key,
)
@staticmethod
@@ -149,7 +155,20 @@ async def health(request: Request) -> dict[str, Any]:
@app.get("/workspaces/bootstrap", tags=["workspace"])
async def bootstrap_workspace() -> RedirectResponse:
async def bootstrap_workspace_redirect() -> RedirectResponse:
return RedirectResponse(url="/", status_code=303)
@app.post("/workspaces/bootstrap", tags=["workspace"])
async def bootstrap_workspace(
request: Request,
turnstile_response: str = Form(alias="cf-turnstile-response", default=""),
) -> Response:
if not turnstile_service.verify_turnstile_token(
turnstile_response, request.client.host if request.client else None
):
return RedirectResponse(url="/?captcha_error=1", status_code=303)
workspace_id = get_workspace_repository().create_workspace_id()
response = RedirectResponse(url=f"/{workspace_id}", status_code=303)
response.set_cookie(