Coverage for src/lexigram/web/security/config/cors.py: 57%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""CORS 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, model_validator 

10 

11 

12class CORSConfig(BaseConfig): 

13 """CORS configuration. 

14 

15 Accepts ``allow_origins`` as a convenience alias for ``allowed_origins`` 

16 at construction time (e.g. ``CORSConfig(allow_origins=["https://..."])``). 

17 Comma-separated strings are also accepted so that values can be supplied 

18 via a single environment variable. 

19 

20 Attributes: 

21 enabled: Enable CORS headers. When ``False`` the middleware should 

22 skip CORS processing entirely. 

23 allowed_origins: Origins that are permitted. 

24 Use ``['*']`` to allow all; combine with ``allow_credentials=False`` 

25 only. 

26 allow_methods: HTTP methods permitted in CORS requests. 

27 allow_headers: Request headers permitted in CORS requests. 

28 expose_headers: Response headers the browser may expose to JS. 

29 allow_credentials: Allow cookies / auth headers in CORS requests. 

30 debug_permissive: When True and debug mode is active, allow any origin 

31 via wildcard (explicit opt-in; no implicit widening). 

32 max_age: Pre-flight cache duration in seconds. 

33 allow_origin_regex: Regex pattern matched against the ``Origin`` header 

34 as a fallback when the origin is not in ``allowed_origins``. 

35 """ 

36 

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

38 

39 enabled: bool = Field(default=True, description="Enable CORS") 

40 allowed_origins: list[str] = Field( 

41 default_factory=lambda: ["*"], 

42 description="Allowed origins (use ['*'] to allow all)", 

43 ) 

44 allow_methods: list[str] = Field( 

45 default_factory=lambda: ["GET", "POST", "PUT", "DELETE", "PATCH"] 

46 ) 

47 allow_headers: list[str] = Field(default_factory=lambda: ["*"]) 

48 expose_headers: list[str] = Field(default_factory=list) 

49 allow_credentials: bool = Field(default=False) 

50 debug_permissive: bool = Field( 

51 default=False, 

52 description="When True and debug mode is active, allow any origin via wildcard " 

53 "(explicit opt-in replacement for the old implicit debug widening)", 

54 ) 

55 max_age: int = Field(default=600) 

56 allow_origin_regex: str | None = Field( 

57 default=None, 

58 description="Regex pattern for allowed origins (matched when not in allowed_origins)", 

59 ) 

60 

61 @model_validator(mode="before") 

62 @classmethod 

63 def _normalize_origins(cls, data: Any) -> Any: 

64 """Accept ``allow_origins`` as alias for ``allowed_origins``. 

65 

66 Also parses comma-separated strings into lists so that 

67 ``CORS_ORIGINS="https://a.com,https://b.com"`` works via env vars. 

68 """ 

69 if not isinstance(data, dict): 

70 return data 

71 data = dict(data) 

72 

73 # Alias allow_origins → allowed_origins (web-compat) 

74 if "allow_origins" in data and "allowed_origins" not in data: 

75 data["allowed_origins"] = data.pop("allow_origins") 

76 

77 # Parse comma-separated strings 

78 origins = data.get("allowed_origins") 

79 if isinstance(origins, str): 

80 data["allowed_origins"] = [ 

81 o.strip() for o in origins.split(",") if o.strip() 

82 ] 

83 

84 return data 

85 

86 @model_validator(mode="after") 

87 def _validate_credentials_with_wildcard(self) -> CORSConfig: 

88 """Reject wildcard origins combined with allow_credentials. 

89 

90 This is a CORS misconfiguration — browsers will reject such responses. 

91 """ 

92 if self.allow_credentials and "*" in self.allowed_origins: 

93 raise ValueError( 

94 "SECURITY ERROR: allow_credentials=True combined with allowed_origins=['*'] " 

95 "is a CORS misconfiguration. Specify explicit origins when credentials are enabled." 

96 ) 

97 return self 

98 

99 @property 

100 def allow_origins(self) -> list[str]: 

101 """Alias for ``allowed_origins`` for backward compatibility.""" 

102 return self.allowed_origins 

103 

104 def to_middleware_kwargs(self) -> dict[str, Any]: 

105 """Return kwargs suitable for passing directly to a CORS middleware.""" 

106 return { 

107 "allow_origins": self.allowed_origins, 

108 "allow_credentials": self.allow_credentials, 

109 "allow_methods": self.allow_methods, 

110 "allow_headers": self.allow_headers, 

111 "max_age": self.max_age, 

112 "allow_origin_regex": self.allow_origin_regex, 

113 "expose_headers": self.expose_headers, 

114 } 

115 

116 def validate_for_environment( 

117 self, env: Environment | None = None 

118 ) -> list[ConfigIssue]: 

119 """Validate CORS for production environments.""" 

120 resolved = env or self.environment 

121 issues: list[ConfigIssue] = [] 

122 

123 if resolved == Environment.PRODUCTION: 

124 if "*" in self.allowed_origins and self.allow_credentials: 

125 issues.append( 

126 ConfigIssue( 

127 field="cors.allowed_origins", 

128 message="Wildcard origins ('*') cannot be used with allow_credentials=True in production", 

129 severity="error", 

130 suggestion="Explicitly list allowed origins or disable credentials", 

131 ) 

132 ) 

133 

134 return issues 

135 

136 

137__all__ = [ 

138 "CORSConfig", 

139]