Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 113 additions & 211 deletions README.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,13 @@ async def start_conversation(
csrf_token = generate_csrf_token()

# Set CSRF token cookie (httponly=False required for double-submit pattern)
# In debug mode, use lax samesite to allow cross-port local development
# Production: SameSite=none allows cross-origin cookie sending (requires Secure)
# Debug: SameSite=lax for cross-port local development without HTTPS
response.set_cookie(
"csrf_token",
csrf_token,
httponly=False,
samesite="lax" if settings.DEBUG else "strict",
samesite="lax" if settings.DEBUG else "none",
secure=not settings.DEBUG,
max_age=3600 * 24, # 24 hours
)
Expand Down
53 changes: 29 additions & 24 deletions app/security/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ async def dispatch(self, request: Request, call_next):
"csrf_token",
csrf_token,
httponly=False, # Must be readable by JavaScript for double-submit pattern
samesite="strict",
secure=True, # Requires HTTPS in production
samesite="none", # Allow cross-origin cookie sending
secure=True, # Required with SameSite=none
max_age=3600 * 24, # 24 hours
)
return response
Expand All @@ -53,11 +53,14 @@ def generate_csrf_token() -> str:


def validate_csrf_token(request: Request):
"""Validate CSRF token using double-submit cookie pattern.
"""Validate CSRF token using double-submit cookie pattern with cross-origin fallback.

In cross-origin development mode (CSRF_SKIP_COOKIE_VALIDATION=True),
only validates that the X-CSRF-Token header is present and non-empty.
This allows cross-origin requests from localhost:3006 to localhost:8000.
The X-CSRF-Token header is always required. When the csrf_token cookie is
also present (same-origin), both must match. When the cookie is absent
(cross-origin — SameSite prevents it), the header alone is accepted.

This is secure because CORS restricts which origins can read the token
from the /chat/start response, so only authorized frontends can obtain it.
"""
settings = get_settings()

Expand All @@ -69,46 +72,48 @@ def validate_csrf_token(request: Request):
)
raise HTTPException(status_code=403, detail="CSRF token missing in header")

# In cross-origin development mode, skip cookie validation
# In development mode, skip cookie validation entirely
if settings.CSRF_SKIP_COOKIE_VALIDATION:
logger.debug(
"CSRF validation (header-only mode) successful", path=request.url.path
)
return

# Get token from cookie
# When cookie is present (same-origin), verify it matches the header
cookie_token = request.cookies.get("csrf_token")
if not cookie_token:
logger.warning(
"CSRF validation failed: No token in cookie", path=request.url.path
if cookie_token:
if not secrets.compare_digest(cookie_token, header_token):
logger.warning(
"CSRF validation failed: Token mismatch",
path=request.url.path,
)
raise HTTPException(status_code=403, detail="CSRF token mismatch")
logger.debug(
"CSRF validation successful (double-submit)", path=request.url.path
)
raise HTTPException(status_code=403, detail="CSRF token missing in cookie")

# Compare tokens
if not secrets.compare_digest(cookie_token, header_token):
logger.warning(
"CSRF validation failed: Token mismatch",
else:
# Cross-origin: cookie absent due to SameSite restrictions.
# Header-only is sufficient — CORS prevents unauthorized origins
# from reading the token returned by /chat/start.
logger.debug(
"CSRF validation successful (header-only, cross-origin)",
path=request.url.path,
has_cookie=bool(cookie_token),
has_header=bool(header_token),
)
raise HTTPException(status_code=403, detail="CSRF token mismatch")

logger.debug("CSRF validation successful", path=request.url.path)


def set_secure_session_cookie(
response: Response, name: str, value: str, max_age: int = 3600, debug: bool = False
):
"""Set a secure session cookie with proper security attributes.

In debug mode, uses 'lax' samesite to allow cross-port local development.
Production: SameSite=none allows cross-origin cookie sending (requires Secure).
Debug: SameSite=lax for cross-port local development without HTTPS.
"""
response.set_cookie(
name,
value,
httponly=True,
samesite="lax" if debug else "strict",
samesite="lax" if debug else "none",
secure=not debug,
max_age=max_age,
)
Loading
Loading