feat(SEC-001): protect workspace bootstrap with turnstile
This commit is contained in:
@@ -17,7 +17,7 @@ def test_homepage_and_options_page_render() -> None:
|
||||
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||
expect(page).to_have_title("NiceGUI")
|
||||
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
|
||||
page.get_by_role("link", name="Get started").click()
|
||||
page.get_by_role("button", name="Get started").click()
|
||||
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
|
||||
workspace_url = page.url
|
||||
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
|
||||
@@ -157,7 +157,7 @@ def test_homepage_and_options_page_render() -> None:
|
||||
second_page = second_context.new_page()
|
||||
second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||
expect(second_page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
|
||||
second_page.get_by_role("link", name="Get started").click()
|
||||
second_page.get_by_role("button", name="Get started").click()
|
||||
second_page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
|
||||
second_workspace_url = second_page.url
|
||||
assert second_workspace_url != workspace_url
|
||||
|
||||
153
tests/test_turnstile.py
Normal file
153
tests/test_turnstile.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_legacy_get_bootstrap_url_redirects_to_welcome_page() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/workspaces/bootstrap", follow_redirects=False)
|
||||
|
||||
assert response.status_code in {302, 303, 307}
|
||||
assert response.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_bootstrap_workspace_rejects_missing_turnstile_token(monkeypatch, tmp_path) -> None:
|
||||
from app.models import workspace as workspace_module
|
||||
from app.models.workspace import WorkspaceRepository
|
||||
|
||||
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
|
||||
monkeypatch.setattr(workspace_module, "_workspace_repo", repo)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/workspaces/bootstrap", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?captcha_error=1"
|
||||
|
||||
|
||||
def test_bootstrap_workspace_creates_workspace_when_turnstile_verification_succeeds(monkeypatch, tmp_path) -> None:
|
||||
from app.models import workspace as workspace_module
|
||||
from app.models.workspace import WorkspaceRepository
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
|
||||
monkeypatch.setattr(workspace_module, "_workspace_repo", repo)
|
||||
monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: True)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/workspaces/bootstrap",
|
||||
data={"cf-turnstile-response": "token-ok"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code in {302, 303, 307}
|
||||
workspace_id = response.headers["location"].strip("/")
|
||||
assert repo.workspace_exists(workspace_id)
|
||||
assert response.cookies.get("workspace_id") == workspace_id
|
||||
|
||||
|
||||
def test_bootstrap_workspace_rejects_failed_turnstile_verification(monkeypatch, tmp_path) -> None:
|
||||
from app.models import workspace as workspace_module
|
||||
from app.models.workspace import WorkspaceRepository
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
|
||||
monkeypatch.setattr(workspace_module, "_workspace_repo", repo)
|
||||
monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/workspaces/bootstrap",
|
||||
data={"cf-turnstile-response": "token-bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?captcha_error=1"
|
||||
assert not any(repo.base_path.iterdir())
|
||||
|
||||
|
||||
def test_turnstile_settings_use_default_test_keys_when_env_is_missing(monkeypatch) -> None:
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.delenv("TURNSTILE_SITE_KEY", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_SECRET_KEY", raising=False)
|
||||
|
||||
settings = turnstile_module.load_turnstile_settings()
|
||||
|
||||
assert settings.site_key == turnstile_module.DEFAULT_TURNSTILE_TEST_SITE_KEY
|
||||
assert settings.secret_key == turnstile_module.DEFAULT_TURNSTILE_TEST_SECRET_KEY
|
||||
assert settings.enabled is True
|
||||
assert settings.uses_test_keys is True
|
||||
assert settings.uses_test_keys is True
|
||||
|
||||
|
||||
def test_turnstile_settings_fail_loudly_without_keys_outside_dev(monkeypatch) -> None:
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
monkeypatch.setenv("APP_ENV", "production")
|
||||
monkeypatch.delenv("TURNSTILE_SITE_KEY", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_SECRET_KEY", raising=False)
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="Turnstile keys must be configured"):
|
||||
turnstile_module.load_turnstile_settings()
|
||||
|
||||
|
||||
def test_turnstile_verification_returns_false_on_transport_error(monkeypatch) -> None:
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
|
||||
def raise_error(*args, **kwargs):
|
||||
raise requests.RequestException("boom")
|
||||
|
||||
monkeypatch.setattr(turnstile_module.requests, "post", raise_error)
|
||||
|
||||
assert turnstile_module.verify_turnstile_token("token") is False
|
||||
|
||||
|
||||
def test_turnstile_settings_support_always_fail_test_keys(monkeypatch) -> None:
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("TURNSTILE_SITE_KEY", turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SITE_KEY)
|
||||
monkeypatch.setenv("TURNSTILE_SECRET_KEY", turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SECRET_KEY)
|
||||
|
||||
settings = turnstile_module.load_turnstile_settings()
|
||||
|
||||
assert settings.site_key == turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SITE_KEY
|
||||
assert settings.secret_key == turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SECRET_KEY
|
||||
assert settings.enabled is True
|
||||
assert settings.uses_test_keys is False
|
||||
|
||||
|
||||
def test_bootstrap_stays_blocked_under_always_fail_turnstile_test_keys(monkeypatch, tmp_path) -> None:
|
||||
from app.models import workspace as workspace_module
|
||||
from app.models.workspace import WorkspaceRepository
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
|
||||
monkeypatch.setattr(workspace_module, "_workspace_repo", repo)
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("TURNSTILE_SITE_KEY", turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SITE_KEY)
|
||||
monkeypatch.setenv("TURNSTILE_SECRET_KEY", turnstile_module.ALWAYS_FAIL_TURNSTILE_TEST_SECRET_KEY)
|
||||
monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/workspaces/bootstrap",
|
||||
data={"cf-turnstile-response": "blocked-token"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?captcha_error=1"
|
||||
assert not any(repo.base_path.iterdir())
|
||||
@@ -48,11 +48,20 @@ def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch)
|
||||
assert "Get started" in response.text
|
||||
|
||||
|
||||
def test_bootstrap_endpoint_creates_workspace_cookie_and_redirects(tmp_path, monkeypatch) -> None:
|
||||
def test_bootstrap_endpoint_requires_turnstile_and_creates_workspace_cookie_and_redirects(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
from app.services import turnstile as turnstile_module
|
||||
|
||||
repo = _install_workspace_repo(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(turnstile_module, "verify_turnstile_token", lambda *args, **kwargs: True)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/workspaces/bootstrap", follow_redirects=False)
|
||||
response = client.post(
|
||||
"/workspaces/bootstrap",
|
||||
data={"cf-turnstile-response": "token-ok"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code in {302, 303, 307}
|
||||
location = response.headers["location"]
|
||||
@@ -74,15 +83,24 @@ def test_root_with_valid_workspace_cookie_redirects_to_workspace(tmp_path, monke
|
||||
assert response.headers["location"] == f"/{workspace_id}"
|
||||
|
||||
|
||||
def test_unknown_workspace_route_shows_friendly_recovery_path(tmp_path, monkeypatch) -> None:
|
||||
def test_unknown_workspace_route_redirects_to_welcome_page(tmp_path, monkeypatch) -> None:
|
||||
_install_workspace_repo(tmp_path, monkeypatch)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/00000000-0000-4000-8000-000000000000")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Workspace not found" in response.text
|
||||
assert "Get started" in response.text
|
||||
assert "Create a private workspace URL" in response.text
|
||||
|
||||
|
||||
def test_arbitrary_fake_workspace_like_path_redirects_to_welcome_page(tmp_path, monkeypatch) -> None:
|
||||
_install_workspace_repo(tmp_path, monkeypatch)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/arbitrary2371568path/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Create a private workspace URL" in response.text
|
||||
|
||||
|
||||
def test_workspace_settings_round_trip_uses_workspace_storage(tmp_path, monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user