Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""Request id, request logging and security headers (AD-16, AD-18)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
import uuid
|
|
|
|
from .logging import redact, request_id_var
|
|
|
|
logger = logging.getLogger("portal.request")
|
|
|
|
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
|
|
|
CSP = (
|
|
"default-src 'self'; "
|
|
"script-src 'self'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'; "
|
|
"form-action 'self'"
|
|
)
|
|
PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()"
|
|
|
|
|
|
class RequestIdMiddleware:
|
|
"""Assign a request id, set the response header and write one log line."""
|
|
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
incoming = request.META.get("HTTP_X_REQUEST_ID", "")
|
|
request_id = incoming if _VALID_REQUEST_ID.match(incoming) else uuid.uuid4().hex
|
|
request.request_id = request_id
|
|
token = request_id_var.set(request_id)
|
|
started = time.monotonic()
|
|
status = 500
|
|
try:
|
|
response = self.get_response(request)
|
|
status = response.status_code
|
|
response["X-Request-ID"] = request_id
|
|
return response
|
|
finally:
|
|
# Path only: query strings and bodies are never logged.
|
|
logger.info(
|
|
"request",
|
|
extra={
|
|
"request_id": request_id,
|
|
"method": request.method,
|
|
"path": redact(request.path),
|
|
"status": status,
|
|
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
|
},
|
|
)
|
|
request_id_var.reset(token)
|
|
|
|
|
|
class SecurityHeadersMiddleware:
|
|
"""Add the Content-Security-Policy and related headers to every response."""
|
|
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
response = self.get_response(request)
|
|
response.headers.setdefault("Content-Security-Policy", CSP)
|
|
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
|
|
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
|
return response
|