diff --git a/Docs/Tasks.md b/Docs/Tasks.md index c87b551..fa755e7 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -33,6 +33,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. - Hardcoding `secure=False` makes production deployment insecure. - Switching to `secure=True` can break local development unless there is an explicit dev override. - The frontend API may need matching CORS and same-site handling when served from a different origin. +- Status: completed — implemented configurable session cookie flags and secure mode support. ### 4. Address session cache invalidation semantics - Where found: `backend/app/dependencies.py` diff --git a/backend/app/config.py b/backend/app/config.py index 953d8b6..3b58252 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,6 +4,8 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_ and validated at startup via the Settings singleton. """ +from typing import Literal + from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -47,6 +49,25 @@ class Settings(BaseSettings): default="UTC", description="IANA timezone name used when displaying timestamps in the UI.", ) + session_cookie_httponly: bool = Field( + default=True, + description=( + "Mark the session cookie as HttpOnly so browser scripts cannot access it." + ), + ) + session_cookie_samesite: Literal["lax", "strict", "none"] = Field( + default="lax", + description=( + "SameSite policy for the session cookie. " + "Use 'lax', 'strict', or 'none' depending on deployment requirements." + ), + ) + session_cookie_secure: bool = Field( + default=False, + description=( + "Set the session cookie Secure flag when the backend is served over HTTPS." + ), + ) cors_allowed_origins: str | list[str] = Field( default_factory=list, description=( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3039ac2..32fae53 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -70,9 +70,9 @@ async def login( response.set_cookie( key=_COOKIE_NAME, value=signed_token, - httponly=True, - samesite="lax", - secure=False, # Set to True in production behind HTTPS + httponly=settings.session_cookie_httponly, + samesite=settings.session_cookie_samesite, + secure=settings.session_cookie_secure, max_age=settings.session_duration_minutes * 60, ) return LoginResponse(token=signed_token, expires_at=session.expires_at) diff --git a/backend/tests/test_routers/test_auth.py b/backend/tests/test_routers/test_auth.py index 007e8b4..a256b9c 100644 --- a/backend/tests/test_routers/test_auth.py +++ b/backend/tests/test_routers/test_auth.py @@ -66,6 +66,22 @@ class TestLogin: assert response.status_code == 200 assert "bangui_session" in response.cookies assert "." in response.cookies["bangui_session"] + set_cookie = response.headers.get("set-cookie", "") + assert "HttpOnly" in set_cookie + assert "SameSite=lax" in set_cookie + + async def test_login_sets_secure_cookie_when_enabled( + self, client: AsyncClient + ) -> None: + """Login sets the Secure flag when session cookies are configured for HTTPS.""" + client._transport.app.state.settings.session_cookie_secure = True + await _do_setup(client) + response = await client.post( + "/api/auth/login", json={"password": "mysecretpass1"} + ) + assert response.status_code == 200 + set_cookie = response.headers.get("set-cookie", "") + assert "Secure" in set_cookie async def test_login_fails_with_wrong_password( self, client: AsyncClient