Harden security controls from REPORT findings
This commit is contained in:
@@ -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, ...]:
|
||||
|
||||
Reference in New Issue
Block a user