Harden session cookie security with configurable cookie flags
This commit is contained in:
@@ -33,6 +33,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
- Hardcoding `secure=False` makes production deployment insecure.
|
- Hardcoding `secure=False` makes production deployment insecure.
|
||||||
- Switching to `secure=True` can break local development unless there is an explicit dev override.
|
- 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.
|
- 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
|
### 4. Address session cache invalidation semantics
|
||||||
- Where found: `backend/app/dependencies.py`
|
- Where found: `backend/app/dependencies.py`
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
|
|||||||
and validated at startup via the Settings singleton.
|
and validated at startup via the Settings singleton.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -47,6 +49,25 @@ class Settings(BaseSettings):
|
|||||||
default="UTC",
|
default="UTC",
|
||||||
description="IANA timezone name used when displaying timestamps in the UI.",
|
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(
|
cors_allowed_origins: str | list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ async def login(
|
|||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=_COOKIE_NAME,
|
key=_COOKIE_NAME,
|
||||||
value=signed_token,
|
value=signed_token,
|
||||||
httponly=True,
|
httponly=settings.session_cookie_httponly,
|
||||||
samesite="lax",
|
samesite=settings.session_cookie_samesite,
|
||||||
secure=False, # Set to True in production behind HTTPS
|
secure=settings.session_cookie_secure,
|
||||||
max_age=settings.session_duration_minutes * 60,
|
max_age=settings.session_duration_minutes * 60,
|
||||||
)
|
)
|
||||||
return LoginResponse(token=signed_token, expires_at=session.expires_at)
|
return LoginResponse(token=signed_token, expires_at=session.expires_at)
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ class TestLogin:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "bangui_session" in response.cookies
|
assert "bangui_session" in response.cookies
|
||||||
assert "." in response.cookies["bangui_session"]
|
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(
|
async def test_login_fails_with_wrong_password(
|
||||||
self, client: AsyncClient
|
self, client: AsyncClient
|
||||||
|
|||||||
Reference in New Issue
Block a user