Harden security controls from REPORT findings

This commit is contained in:
2026-03-01 13:32:08 -03:00
parent da5cbc2c01
commit bdd97d1c62
20 changed files with 1455 additions and 97 deletions

View File

@@ -19,6 +19,9 @@ class Settings(BaseSettings):
app_env: str = "development"
database_url: str = "postgresql+psycopg://dcm:dcm@db:5432/dcm"
redis_url: str = "redis://redis:6379/0"
redis_security_mode: str = "auto"
redis_tls_mode: str = "auto"
allow_development_anonymous_user_access: bool = True
storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50
@@ -26,6 +29,7 @@ class Settings(BaseSettings):
max_upload_request_size_bytes: int = 100 * 1024 * 1024
max_zip_members: int = 250
max_zip_depth: int = 2
max_zip_descendants_per_root: int = 1000
max_zip_member_uncompressed_bytes: int = 25 * 1024 * 1024
max_zip_total_uncompressed_bytes: int = 150 * 1024 * 1024
max_zip_compression_ratio: float = 120.0
@@ -44,12 +48,13 @@ class Settings(BaseSettings):
default_openai_timeout_seconds: int = 45
default_openai_handwriting_enabled: bool = True
default_openai_api_key: str = ""
app_settings_encryption_key: str = ""
default_summary_model: str = "gpt-4.1-mini"
default_routing_model: str = "gpt-4.1-mini"
typesense_protocol: str = "http"
typesense_host: str = "typesense"
typesense_port: int = 8108
typesense_api_key: str = "dcm-typesense-key"
typesense_api_key: str = ""
typesense_collection_name: str = "documents"
typesense_timeout_seconds: int = 120
typesense_num_retries: int = 0
@@ -58,6 +63,111 @@ class Settings(BaseSettings):
LOCAL_HOSTNAME_SUFFIXES = (".local", ".internal", ".home.arpa")
SCRIPT_CAPABLE_INLINE_MIME_TYPES = frozenset(
{
"application/ecmascript",
"application/javascript",
"application/x-javascript",
"application/xhtml+xml",
"image/svg+xml",
"text/ecmascript",
"text/html",
"text/javascript",
}
)
SCRIPT_CAPABLE_XML_MIME_TYPES = frozenset({"application/xml", "text/xml"})
REDIS_SECURITY_MODES = frozenset({"auto", "strict", "compat"})
REDIS_TLS_MODES = frozenset({"auto", "required", "allow_insecure"})
def _is_production_environment(app_env: str) -> bool:
"""Returns whether the runtime environment should enforce production-only security gates."""
normalized = app_env.strip().lower()
return normalized in {"production", "prod"}
def _normalize_redis_security_mode(raw_mode: str) -> str:
"""Normalizes Redis security mode values into one supported mode."""
normalized = raw_mode.strip().lower()
if normalized not in REDIS_SECURITY_MODES:
return "auto"
return normalized
def _normalize_redis_tls_mode(raw_mode: str) -> str:
"""Normalizes Redis TLS mode values into one supported mode."""
normalized = raw_mode.strip().lower()
if normalized not in REDIS_TLS_MODES:
return "auto"
return normalized
def validate_redis_url_security(
redis_url: str,
*,
app_env: str | None = None,
security_mode: str | None = None,
tls_mode: str | None = None,
) -> str:
"""Validates Redis URL security posture with production fail-closed defaults."""
settings = get_settings()
resolved_app_env = app_env if app_env is not None else settings.app_env
resolved_security_mode = (
_normalize_redis_security_mode(security_mode)
if security_mode is not None
else _normalize_redis_security_mode(settings.redis_security_mode)
)
resolved_tls_mode = (
_normalize_redis_tls_mode(tls_mode)
if tls_mode is not None
else _normalize_redis_tls_mode(settings.redis_tls_mode)
)
candidate = redis_url.strip()
if not candidate:
raise ValueError("Redis URL must not be empty")
parsed = urlparse(candidate)
scheme = parsed.scheme.lower()
if scheme not in {"redis", "rediss"}:
raise ValueError("Redis URL must use redis:// or rediss://")
if not parsed.hostname:
raise ValueError("Redis URL must include a hostname")
strict_security = (
resolved_security_mode == "strict"
or (resolved_security_mode == "auto" and _is_production_environment(resolved_app_env))
)
require_tls = (
resolved_tls_mode == "required"
or (resolved_tls_mode == "auto" and strict_security)
)
has_password = bool(parsed.password and parsed.password.strip())
uses_tls = scheme == "rediss"
if strict_security and not has_password:
raise ValueError("Redis URL must include authentication when security mode is strict")
if require_tls and not uses_tls:
raise ValueError("Redis URL must use rediss:// when TLS is required")
return candidate
def is_inline_preview_mime_type_safe(mime_type: str) -> bool:
"""Returns whether a MIME type is safe to serve inline from untrusted document uploads."""
normalized = mime_type.split(";", 1)[0].strip().lower() if mime_type else ""
if not normalized:
return False
if normalized in SCRIPT_CAPABLE_INLINE_MIME_TYPES:
return False
if normalized in SCRIPT_CAPABLE_XML_MIME_TYPES or normalized.endswith("+xml"):
return False
return True
def _normalize_allowlist(allowlist: object) -> tuple[str, ...]: