feat: enhance error handling and configuration in backend
- Added ORCID_REDIRECT_URI to docker-compose for OAuth callback. - Refactored CORS and trusted hosts settings in configuration for better clarity. - Introduced a new function to validate publication IDs and provide explicit error messages for researcher IDs. - Updated rate limiting strategy to simplify configuration. - Improved security headers middleware to safely remove sensitive headers.
This commit is contained in:
+18
-17
@@ -71,9 +71,9 @@ class Settings(BaseSettings):
|
||||
ORCID_OAUTH_STATE_COOKIE: str = "orcid_oauth_state"
|
||||
ORCID_OAUTH_STATE_TTL_SECONDS: int = 600
|
||||
|
||||
CORS_ALLOWED_ORIGINS: List[str] = Field(default_factory=list)
|
||||
CORS_ALLOWED_ORIGINS: str = ""
|
||||
|
||||
TRUSTED_HOSTS: List[str] = Field(default_factory=lambda: ["*"])
|
||||
TRUSTED_HOSTS: str = "*"
|
||||
|
||||
RATE_LIMIT_DEFAULT: str = "60/minute"
|
||||
RATE_LIMIT_AUTH: str = "10/minute"
|
||||
@@ -92,19 +92,11 @@ class Settings(BaseSettings):
|
||||
SECURITY_HSTS_INCLUDE_SUBDOMAINS: bool = True
|
||||
SECURITY_HSTS_PRELOAD: bool = False
|
||||
|
||||
@field_validator("CORS_ALLOWED_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def _parse_cors(cls, v):
|
||||
return _split_csv(v)
|
||||
|
||||
@field_validator("TRUSTED_HOSTS", mode="before")
|
||||
@classmethod
|
||||
def _parse_trusted_hosts(cls, v):
|
||||
parsed = _split_csv(v) if not isinstance(v, list) else v
|
||||
return parsed or ["*"]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_security(self) -> "Settings":
|
||||
cors_origins = self.cors_allowed_origins
|
||||
trusted_hosts = self.trusted_hosts
|
||||
|
||||
if self.ENVIRONMENT == "production":
|
||||
weak = {"change_me", "changeme", "secret", "password", ""}
|
||||
if self.JWT_SECRET.strip().lower() in weak:
|
||||
@@ -116,11 +108,11 @@ class Settings(BaseSettings):
|
||||
raise ValueError(
|
||||
"JWT_SECRET debe tener al menos 32 caracteres en producción."
|
||||
)
|
||||
if "*" in self.CORS_ALLOWED_ORIGINS:
|
||||
if "*" in cors_origins:
|
||||
raise ValueError(
|
||||
"CORS_ALLOWED_ORIGINS no puede contener '*' en producción."
|
||||
)
|
||||
if not self.CORS_ALLOWED_ORIGINS:
|
||||
if not cors_origins:
|
||||
raise ValueError(
|
||||
"CORS_ALLOWED_ORIGINS debe definirse explícitamente en producción."
|
||||
)
|
||||
@@ -128,12 +120,12 @@ class Settings(BaseSettings):
|
||||
raise ValueError(
|
||||
"API_KEY_VALUE debe tener al menos 24 caracteres en producción."
|
||||
)
|
||||
if self.TRUSTED_HOSTS == ["*"]:
|
||||
if trusted_hosts == ["*"]:
|
||||
raise ValueError(
|
||||
"TRUSTED_HOSTS debe definirse explícitamente en producción."
|
||||
)
|
||||
|
||||
for origin in self.CORS_ALLOWED_ORIGINS:
|
||||
for origin in cors_origins:
|
||||
parsed = urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"Origen CORS inválido: {origin!r}")
|
||||
@@ -144,6 +136,15 @@ class Settings(BaseSettings):
|
||||
def is_production(self) -> bool:
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
@property
|
||||
def cors_allowed_origins(self) -> List[str]:
|
||||
return _split_csv(self.CORS_ALLOWED_ORIGINS)
|
||||
|
||||
@property
|
||||
def trusted_hosts(self) -> List[str]:
|
||||
parsed = _split_csv(self.TRUSTED_HOSTS)
|
||||
return parsed or ["*"]
|
||||
|
||||
@property
|
||||
def docs_url(self) -> str | None:
|
||||
return "/docs" if self.DOCS_ENABLED else None
|
||||
|
||||
@@ -39,8 +39,8 @@ def _build_limiter() -> Limiter:
|
||||
key_func=_key_func,
|
||||
default_limits=[settings.RATE_LIMIT_DEFAULT],
|
||||
storage_uri=storage_uri,
|
||||
headers_enabled=True,
|
||||
strategy="fixed-window-elastic-expiry",
|
||||
headers_enabled=False,
|
||||
strategy="fixed-window",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
"""
|
||||
Middleware de cabeceras de seguridad HTTP.
|
||||
|
||||
Aplica un perfil seguro por defecto:
|
||||
- Strict-Transport-Security (HSTS) — fuerza HTTPS en navegadores compatibles.
|
||||
- X-Content-Type-Options: nosniff
|
||||
- X-Frame-Options: DENY (clickjacking)
|
||||
- Referrer-Policy: strict-origin-when-cross-origin
|
||||
- Permissions-Policy: bloquea APIs sensibles por defecto
|
||||
- Cross-Origin-Opener-Policy / Resource-Policy: aislamiento del navegador
|
||||
- Content-Security-Policy laxa para Swagger/OpenAPI (CDN), restrictiva para el resto.
|
||||
|
||||
NOTA: El frontend SPA tiene su propia CSP en su servidor. Aquí
|
||||
endurecemos lo que sirve el backend (JSON, XML, ZIP, /docs, /redoc, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -82,7 +66,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
hsts += "; preload"
|
||||
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||
|
||||
response.headers.pop("Server", None)
|
||||
response.headers.pop("X-Powered-By", None)
|
||||
# `MutableHeaders` no implementa `.pop()`. Eliminamos de forma segura.
|
||||
if "server" in response.headers:
|
||||
del response.headers["server"]
|
||||
if "x-powered-by" in response.headers:
|
||||
del response.headers["x-powered-by"]
|
||||
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user