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

1"""CSRF configuration.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any, ClassVar 

6 

7from lexigram.config.base import BaseConfig 

8from lexigram.contracts.core.config import ConfigIssue, Environment 

9from lexigram.validation import ConfigDict, Field, SecretStr, field_validator 

10 

11 

12class CSRFConfig(BaseConfig): 

13 """Configuration for CSRF protection middleware. 

14 

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 """ 

33 

34 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

35 

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 ) 

70 

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)) 

78 

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] = [] 

85 

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 ) 

102 

103 return issues 

104 

105 

106__all__ = [ 

107 "CSRFConfig", 

108]