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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""CORS 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, model_validator
12class CORSConfig(BaseConfig):
13 """CORS configuration.
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.
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 """
37 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
61 @model_validator(mode="before")
62 @classmethod
63 def _normalize_origins(cls, data: Any) -> Any:
64 """Accept ``allow_origins`` as alias for ``allowed_origins``.
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)
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")
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 ]
84 return data
86 @model_validator(mode="after")
87 def _validate_credentials_with_wildcard(self) -> CORSConfig:
88 """Reject wildcard origins combined with allow_credentials.
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
99 @property
100 def allow_origins(self) -> list[str]:
101 """Alias for ``allowed_origins`` for backward compatibility."""
102 return self.allowed_origins
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 }
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] = []
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 )
134 return issues
137__all__ = [
138 "CORSConfig",
139]