feat: bind operator mode to public origin (Closes #609)
This commit is contained in:
1 parent
075a277265
commit
8ea46974ff
6 files changed
+138
-7
No files matched your search
@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
@@ -15,6 +16,7 @@ from src.views import FRONTEND_BUILD
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
@@ -1364,6 +1366,78 @@ async def test_mutation_requires_same_origin_and_session_csrf(access_control, mo
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_mode_rejects_unconfigured_or_foreign_public_origin_before_auth(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
missing = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "http://test")
|
||||
insecure = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
foreign_host = await client.get("/login")
|
||||
|
||||
assert missing.status_code == 503
|
||||
assert insecure.status_code == 503
|
||||
assert foreign_host.status_code == 421
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_canonical_origin_drives_csrf_and_passkey_defaults(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_RP_ID", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_ORIGIN", raising=False)
|
||||
calls = 0
|
||||
|
||||
async def mark_read(notification_id):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": notification_id, "read": True}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://dashboard.example"
|
||||
) as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
valid = await client.patch(
|
||||
"/api/v1/notifications/7/read",
|
||||
headers={
|
||||
"Origin": "https://dashboard.example",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
)
|
||||
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "https",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
"headers": [(b"host", b"attacker.invalid")],
|
||||
"server": ("attacker.invalid", 443),
|
||||
"client": ("127.0.0.1", 1),
|
||||
})
|
||||
assert main._passkey_relying_party(request) == (
|
||||
"dashboard.example",
|
||||
"https://dashboard.example",
|
||||
)
|
||||
assert valid.status_code == 200
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -30,6 +30,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
@@ -43,6 +44,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
|
||||
assert [response.status_code for response in responses] == [200, 200, 200, 401]
|
||||
for response in responses:
|
||||
assert_browser_security_boundary(response)
|
||||
assert response.headers["strict-transport-security"] == "max-age=31536000"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -57,3 +59,13 @@ async def test_security_boundary_covers_fail_closed_authentication_response(monk
|
||||
|
||||
assert response.status_code == 503
|
||||
assert_browser_security_boundary(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insecure_local_mode_does_not_emit_hsts():
|
||||
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 123))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client:
|
||||
response = await client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "strict-transport-security" not in response.headers
|
||||
Reference in new issue
Block a user