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

@@ -63,8 +63,7 @@ def legacy_backtests_page(request: Request):
def workspace_backtests_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
render_workspace_recovery()
return
return RedirectResponse(url="/", status_code=307)
_render_backtests_page(workspace_id=workspace_id)

View File

@@ -161,7 +161,7 @@ def render_workspace_recovery(title: str = "Workspace not found", message: str |
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(resolved_message).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
ui.link("Get started", "/").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(

View File

@@ -41,8 +41,7 @@ def legacy_event_comparison_page(request: Request):
def workspace_event_comparison_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
render_workspace_recovery()
return
return RedirectResponse(url="/", status_code=307)
_render_event_comparison_page(workspace_id=workspace_id)

View File

@@ -70,8 +70,7 @@ def legacy_hedge_page(request: Request):
def workspace_hedge_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
render_workspace_recovery()
return
return RedirectResponse(url="/", status_code=307)
_render_hedge_page(workspace_id=workspace_id)

View File

@@ -11,6 +11,7 @@ from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.runtime import get_data_service
from app.services.turnstile import load_turnstile_settings
_DEFAULT_CASH_BUFFER = 18_500.0
@@ -55,7 +56,7 @@ def _render_workspace_recovery(title: str, message: str) -> None:
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(message).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
ui.link("Get started", "/").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
@@ -69,6 +70,7 @@ def welcome_page(request: Request):
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
captcha_error = request.query_params.get("captcha_error") == "1"
with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"):
with ui.card().classes(
@@ -79,10 +81,25 @@ def welcome_page(request: Request):
ui.label(
"Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-4 pt-4"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
if captcha_error:
ui.label("CAPTCHA verification failed. Please retry the Turnstile challenge.").classes(
"rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/30 dark:text-rose-300"
)
with ui.row().classes("items-center gap-4 pt-4"):
turnstile = load_turnstile_settings()
ui.add_body_html(
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'
)
hidden_token = (
'<input type="hidden" name="cf-turnstile-response" value="test-token" />'
if turnstile.uses_test_keys
else ""
)
ui.html(f"""<form method="post" action="/workspaces/bootstrap" class="flex items-center gap-4">
{hidden_token}
<div class="cf-turnstile" data-sitekey="{turnstile.site_key}"></div>
<button type="submit" class="rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900">Get started</button>
</form>""")
ui.label("You can always create a fresh workspace later if a link is lost.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
@@ -102,11 +119,7 @@ def legacy_overview_page(request: Request):
async def overview_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
_render_workspace_recovery(
"Workspace not found",
"The workspace link looks missing or expired. Create a new workspace or return to the welcome page.",
)
return
return RedirectResponse(url="/", status_code=307)
config = repo.load_portfolio_config(workspace_id)
data_service = get_data_service()
@@ -224,15 +237,17 @@ async def overview_page(workspace_id: str) -> None:
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"
):
with ui.column().classes("gap-1"):
ui.label(event.message).classes("text-sm font-medium text-slate-900 dark:text-slate-100")
ui.label(event.message).classes(
"text-sm font-medium text-slate-900 dark:text-slate-100"
)
ui.label(
f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}"
).classes("text-xs text-slate-500 dark:text-slate-400")
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
else:
ui.label("No alert history yet. Alerts will be logged once the warning threshold is crossed.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
ui.label(
"No alert history yet. Alerts will be logged once the warning threshold is crossed."
).classes("text-sm text-slate-500 dark:text-slate-400")
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"

View File

@@ -27,7 +27,7 @@ def _render_workspace_recovery() -> None:
"The requested workspace is unavailable. Start a new workspace or return to the welcome page."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
ui.link("Get started", "/").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
@@ -49,8 +49,7 @@ def settings_page(workspace_id: str) -> None:
"""Settings page with workspace-scoped persistent portfolio configuration."""
workspace_repo = get_workspace_repository()
if not workspace_repo.workspace_exists(workspace_id):
_render_workspace_recovery()
return
return RedirectResponse(url="/", status_code=307)
config = workspace_repo.load_portfolio_config(workspace_id)
alert_service = AlertService()