Coverage for src/lexigram/web/security/config/csrf.py: 70%
37 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""CSRF configuration."""
3from __future__ import annotations
5from typing import Any, ClassVar
7from lexigram.config.base import BaseConfig
8from lexigram.contracts.core.config import ConfigIssue, Environment
9from lexigram.validation import ConfigDict, Field, SecretStr, field_validator
12class CSRFConfig(BaseConfig):
13 """Configuration for CSRF protection middleware.
15 Attributes:
16 enabled: Whether CSRF protection is active.
17 cookie_name: Name of the cookie storing the CSRF token.
18 header_name: Name of the header containing the client CSRF token.
19 cookie_secure: Whether the cookie should be marked as secure (HTTPS only).
20 cookie_httponly: Whether the cookie should be marked as HttpOnly.
21 cookie_samesite: Value for the SameSite attribute ('Lax', 'Strict', or 'None').
22 cookie_domain: Optional domain attribute for the CSRF cookie.
23 cookie_path: Path attribute for the CSRF cookie.
24 token_length: Length of the generated CSRF token in bytes.
25 token_ttl: Lifetime in seconds for synchronizer-mode tokens stored in cache.
26 excluded_paths: URL path prefixes exempt from CSRF validation for
27 cookie-less requests; cookie-bearing requests on these paths are
28 still validated.
29 exclude_content_types: ``Content-Type`` values that bypass CSRF validation.
30 exclude_auth_schemes: Authorization header schemes that bypass CSRF validation.
31 secret_key: HMAC secret used to sign and verify CSRF tokens.
32 """
34 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
36 enabled: bool = Field(default=False)
37 cookie_name: str = Field(default="csrf_token")
38 header_name: str = Field(default="X-CSRF-Token")
39 cookie_secure: bool = Field(default=True)
40 cookie_httponly: bool = Field(default=True)
41 cookie_samesite: str = Field(default="Lax")
42 cookie_domain: str | None = Field(default=None)
43 cookie_path: str = Field(default="/")
44 token_length: int = Field(default=32)
45 token_ttl: int = Field(
46 default=3600,
47 description="TTL in seconds for synchronizer-mode tokens stored in cache.",
48 )
49 excluded_paths: list[str] = Field(
50 default_factory=list,
51 description="URL path prefixes exempt from CSRF validation for cookie-less "
52 "requests; cookie-bearing requests on these paths are still validated.",
53 )
54 exclude_content_types: list[str] = Field(
55 default_factory=list,
56 description="Content-Type values that bypass CSRF validation (explicit opt-in — "
57 "JSON requests are validated by default so cookie-authenticated SPA flows "
58 "cannot bypass CSRF).",
59 )
60 exclude_auth_schemes: list[str] = Field(
61 default_factory=list,
62 description="Authorization header schemes that bypass CSRF validation (explicit opt-in).",
63 )
64 secret_key: SecretStr | None = Field(
65 default=None,
66 exclude=True,
67 description="HMAC secret used to sign and verify CSRF tokens "
68 "(populated via LEX_WEB__SECURITY__CSRF__SECRET_KEY)",
69 )
71 @field_validator("secret_key")
72 @classmethod
73 def _coerce_secret_key(cls, value: Any) -> Any:
74 """Accept plain strings from env/YAML; store as SecretStr."""
75 if value is None or isinstance(value, SecretStr):
76 return value
77 return SecretStr(str(value))
79 def validate_for_environment(
80 self, env: Environment | None = None
81 ) -> list[ConfigIssue]:
82 """CSRF production validation."""
83 resolved = env or self.environment
84 issues: list[ConfigIssue] = []
86 if resolved == Environment.PRODUCTION and self.enabled:
87 csrf_raw = self.secret_key
88 csrf_key: str | None = (
89 csrf_raw.get_secret_value()
90 if isinstance(csrf_raw, SecretStr)
91 else csrf_raw
92 )
93 if not csrf_key or csrf_key.strip() == "":
94 issues.append(
95 ConfigIssue(
96 field="csrf.secret_key",
97 message="CSRF is enabled but no secret_key is set in production",
98 severity="warning",
99 suggestion="Set a strong, random secret_key if using HMAC-based CSRF protection",
100 )
101 )
103 return issues
106__all__ = [
107 "CSRFConfig",
108]