@@ -128,12 +128,15 @@ class ProviderAuthSubConfig(BaseModel):
128128 )
129129
130130
131+ _NO_AUTH_STRATEGIES : frozenset [str | None ] = frozenset ({"none" , "" , None })
132+
133+
131134class AuthConfig (BaseModel ):
132135 """Authentication configuration."""
133136
134137 model_config = ConfigDict (extra = "forbid" )
135138
136- enabled : bool = Field (False , description = "Enable authentication" )
139+ enabled : bool = Field (True , description = "Enable authentication" )
137140 strategy : str = Field (
138141 "none" ,
139142 description = "Authentication strategy (none, bearer_token, bearer_token_enhanced, iam, cognito)" ,
@@ -152,6 +155,31 @@ class AuthConfig(BaseModel):
152155 None , description = "Provider-specific auth configuration"
153156 )
154157
158+ @model_validator (mode = "after" )
159+ def _reject_enabled_with_no_strategy (self ) -> "AuthConfig" :
160+ """Fail hard when auth.enabled=True but no real strategy is configured.
161+
162+ The "none" strategy (NoAuthStrategy) is a pass-through that grants every
163+ anonymous caller permissions=["*"]. Combining it with enabled=True is a
164+ silent fail-open: the server advertises "auth is on" while enforcing
165+ nothing. Any construction that would produce this combination is a
166+ misconfiguration and is rejected at build time so the error surfaces
167+ immediately rather than at request time.
168+
169+ To run with authentication disabled, use AuthConfig(enabled=False)
170+ explicitly. To enable auth, also set strategy to a real strategy name
171+ (e.g. "bearer_token", "iam", "cognito").
172+ """
173+ if self .enabled and self .strategy in _NO_AUTH_STRATEGIES :
174+ raise ValueError (
175+ "auth.enabled=True requires a real authentication strategy. "
176+ f"Got strategy={ self .strategy !r} , which is a pass-through that enforces nothing. "
177+ "Either set enabled=False (to disable auth explicitly) "
178+ "or set strategy to a real strategy name "
179+ "(e.g. 'bearer_token', 'bearer_token_enhanced', 'iam', 'cognito')."
180+ )
181+ return self
182+
155183
156184class CORSConfig (BaseModel ):
157185 """CORS configuration.
@@ -161,6 +189,15 @@ class CORSConfig(BaseModel):
161189 Operators who bind the server to ``0.0.0.0`` (network exposure) MUST also
162190 update ``origins`` and ``trusted_hosts`` to the actual client origins they
163191 want to permit.
192+
193+ **Security note — credentials + wildcard origin:**
194+ The combination of ``credentials=True`` and ``origins=["*"]`` is rejected at
195+ validation time. Browsers refuse to honour ``Access-Control-Allow-Credentials:
196+ true`` when the server responds with ``Access-Control-Allow-Origin: *``
197+ (the spec requires an explicit origin in that case). Accepting this combo
198+ silently would produce a configuration that either breaks browsers or, in
199+ environments where the restriction is relaxed, grants credential access to
200+ any origin.
164201 """
165202
166203 enabled : bool = Field (True , description = "Enable CORS" )
@@ -177,6 +214,49 @@ class CORSConfig(BaseModel):
177214 headers : list [str ] = Field (["*" ], description = "Allowed headers" )
178215 credentials : bool = Field (False , description = "Allow credentials" )
179216
217+ @model_validator (mode = "after" )
218+ def _reject_credentials_with_wildcard_origin (self ) -> "CORSConfig" :
219+ """Reject insecure combinations of allow_credentials=True with wildcard origins/headers.
220+
221+ Browsers reject ``Access-Control-Allow-Credentials: true`` when the
222+ server sends ``Access-Control-Allow-Origin: *``. Beyond the bare ``*``
223+ case, this validator also catches:
224+
225+ - Whitespace-padded wildcards (e.g. ``" * "`` or ``" *"``)
226+ - Subdomain/path wildcards (any origin containing ``*``, e.g.
227+ ``"https://*.example.com"``) — browsers reject credentials with any
228+ wildcard origin pattern, not just a bare ``*``
229+ - ``headers=["*"]`` with ``credentials=True`` — the spec forbids
230+ ``Access-Control-Allow-Headers: *`` when credentials are in play
231+
232+ When ``credentials=False`` (the default) none of these restrictions apply.
233+ """
234+ if not self .credentials :
235+ return self
236+
237+ # Check each origin: strip whitespace and reject any that contain '*'.
238+ for origin in self .origins :
239+ if "*" in origin .strip ():
240+ raise ValueError (
241+ "cors.credentials=true is incompatible with cors.origins containing "
242+ f"a wildcard pattern (got { origin !r} ). "
243+ "Browsers refuse to send credentials to wildcard origins, including "
244+ "subdomain wildcards like 'https://*.example.com'. "
245+ "Replace wildcard entries with the explicit origins you want to permit, "
246+ "or set cors.credentials=false if credentials are not needed."
247+ )
248+
249+ # headers=["*"] with credentials is also forbidden by the CORS spec.
250+ if "*" in self .headers :
251+ raise ValueError (
252+ "cors.credentials=true is incompatible with cors.headers containing '*'. "
253+ "The CORS spec forbids 'Access-Control-Allow-Headers: *' when credentials "
254+ "are in play. List the specific request headers you want to allow, "
255+ "or set cors.credentials=false if credentials are not needed."
256+ )
257+
258+ return self
259+
180260
181261class ServerConfig (BaseModel ):
182262 """REST API server configuration."""
@@ -203,7 +283,17 @@ class ServerConfig(BaseModel):
203283 openapi_url : str = Field ("/openapi.json" , description = "OpenAPI schema URL" )
204284
205285 # Authentication and CORS
206- auth : AuthConfig = Field (default_factory = AuthConfig , description = "Authentication configuration" ) # type: ignore[arg-type]
286+ #
287+ # Default: auth disabled. The "none" strategy is a pass-through that grants
288+ # every anonymous caller permissions=["*"]; combining it with enabled=True
289+ # is silently fail-open. Operators who want auth MUST set both enabled=True
290+ # AND a real strategy name in their config file. A bare ServerConfig()
291+ # therefore boots with auth off (honest posture) rather than appearing to
292+ # have auth on while enforcing nothing.
293+ auth : AuthConfig = Field ( # type: ignore[arg-type]
294+ default_factory = lambda : AuthConfig (enabled = False ), # type: ignore[call-arg]
295+ description = "Authentication configuration" ,
296+ )
207297 cors : CORSConfig = Field (default_factory = CORSConfig , description = "CORS configuration" ) # type: ignore[arg-type]
208298
209299 # Security
0 commit comments