feat(security): add request size limits to API endpoints
- Add RequestSizeLimitMiddleware that checks Content-Length header before request body is read: non-multipart requests capped at MAX_REQUEST_BODY_SIZE (default 1 MB), multipart uploads capped at MAX_UPLOAD_SIZE (default 1 GB). Returns HTTP 413 on violation. - Register middleware in app/main.py - Add max_request_body_size setting to app/config.py - Fix ui_upload in files.py to check Content-Length early and read in 64 KB chunks (bounded memory usage), removing the post-write os.path.getsize check - Document MAX_REQUEST_BODY_SIZE in .env.demo and ConfigurationGuide.md - Mark SECURITY_AUDIT.md item #4 as resolved - Add 9 tests in test_request_size_limit.py - Update test_upload_file_too_large to use patch.object instead of the now-unused os.path.getsize mock Closes #173 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+65
-21
@@ -33,7 +33,9 @@ class Settings(BaseSettings):
|
||||
# Making Paperless optional
|
||||
paperless_ngx_api_token: Optional[str] = None
|
||||
paperless_host: Optional[str] = None
|
||||
paperless_custom_field_absender: Optional[str] = None # Name of the "absender" custom field in Paperless
|
||||
paperless_custom_field_absender: Optional[str] = (
|
||||
None # Name of the "absender" custom field in Paperless
|
||||
)
|
||||
# JSON mapping of metadata field names to Paperless custom field names
|
||||
# Example: {"absender": "Sender", "empfaenger": "Recipient",
|
||||
# "language": "Language", "correspondent": "Correspondent"}
|
||||
@@ -113,7 +115,9 @@ class Settings(BaseSettings):
|
||||
sftp_private_key_passphrase: Optional[str] = None
|
||||
# Security: Host key verification is enabled by default for security
|
||||
# In development/testing, set to True to disable verification (not recommended)
|
||||
sftp_disable_host_key_verification: bool = False # Default enforces host key verification
|
||||
sftp_disable_host_key_verification: bool = (
|
||||
False # Default enforces host key verification
|
||||
)
|
||||
|
||||
# Email settings
|
||||
email_host: Optional[str] = None
|
||||
@@ -121,13 +125,17 @@ class Settings(BaseSettings):
|
||||
email_username: Optional[str] = None
|
||||
email_password: Optional[str] = None
|
||||
email_use_tls: bool = True
|
||||
email_sender: Optional[str] = None # From address, defaults to email_username if not set
|
||||
email_sender: Optional[str] = (
|
||||
None # From address, defaults to email_username if not set
|
||||
)
|
||||
email_default_recipient: Optional[str] = None
|
||||
|
||||
# OneDrive settings
|
||||
onedrive_client_id: Optional[str] = None
|
||||
onedrive_client_secret: Optional[str] = None
|
||||
onedrive_tenant_id: Optional[str] = "common" # Default to "common" for personal accounts
|
||||
onedrive_tenant_id: Optional[str] = (
|
||||
"common" # Default to "common" for personal accounts
|
||||
)
|
||||
onedrive_refresh_token: Optional[str] = None # Required for personal accounts
|
||||
onedrive_folder_path: Optional[str] = None
|
||||
|
||||
@@ -145,31 +153,43 @@ class Settings(BaseSettings):
|
||||
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
|
||||
|
||||
# HTTP request settings
|
||||
http_request_timeout: int = 120 # Default timeout for HTTP requests in seconds (handles large file operations)
|
||||
http_request_timeout: int = (
|
||||
120 # Default timeout for HTTP requests in seconds (handles large file operations)
|
||||
)
|
||||
|
||||
# Feature flags
|
||||
allow_file_delete: bool = True # Default to allowing file deletion from database
|
||||
|
||||
# Batch processing settings
|
||||
processall_throttle_threshold: int = Field(
|
||||
default=20, description="Number of files above which throttling is applied in /processall endpoint"
|
||||
default=20,
|
||||
description="Number of files above which throttling is applied in /processall endpoint",
|
||||
)
|
||||
processall_throttle_delay: int = Field(
|
||||
default=3, description="Delay in seconds between each task submission when throttling in /processall"
|
||||
default=3,
|
||||
description="Delay in seconds between each task submission when throttling in /processall",
|
||||
)
|
||||
|
||||
# Notification settings
|
||||
notification_urls: Union[List[str], str] = Field(
|
||||
default_factory=list, description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)"
|
||||
default_factory=list,
|
||||
description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)",
|
||||
)
|
||||
notify_on_task_failure: bool = Field(
|
||||
default=True, description="Send notifications when Celery tasks fail"
|
||||
)
|
||||
notify_on_task_failure: bool = Field(default=True, description="Send notifications when Celery tasks fail")
|
||||
notify_on_credential_failure: bool = Field(
|
||||
default=True, description="Send notifications when credential checks fail"
|
||||
)
|
||||
notify_on_startup: bool = Field(default=True, description="Send notifications when application starts")
|
||||
notify_on_shutdown: bool = Field(default=False, description="Send notifications when application shuts down")
|
||||
notify_on_startup: bool = Field(
|
||||
default=True, description="Send notifications when application starts"
|
||||
)
|
||||
notify_on_shutdown: bool = Field(
|
||||
default=False, description="Send notifications when application shuts down"
|
||||
)
|
||||
notify_on_file_processed: bool = Field(
|
||||
default=True, description="Send notifications when files are successfully processed"
|
||||
default=True,
|
||||
description="Send notifications when files are successfully processed",
|
||||
)
|
||||
|
||||
# File upload size limits (for security - see SECURITY_AUDIT.md)
|
||||
@@ -184,6 +204,14 @@ class Settings(BaseSettings):
|
||||
" it will be split into smaller chunks for processing. Default: None (no splitting)."
|
||||
),
|
||||
)
|
||||
max_request_body_size: int = Field(
|
||||
default=1048576, # 1MB in bytes (1024 * 1024)
|
||||
description=(
|
||||
"Maximum request body size in bytes for non-file-upload requests. Default: 1MB."
|
||||
" Prevents memory exhaustion attacks via oversized JSON/form payloads."
|
||||
" File uploads are governed by MAX_UPLOAD_SIZE instead."
|
||||
),
|
||||
)
|
||||
|
||||
# Deduplication settings - prevents processing of duplicate files
|
||||
enable_deduplication: bool = Field(
|
||||
@@ -228,7 +256,9 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Content-Security-Policy (CSP) - Controls resource loading
|
||||
security_header_csp_enabled: bool = Field(default=True, description="Enable CSP header.")
|
||||
security_header_csp_enabled: bool = Field(
|
||||
default=True, description="Enable CSP header."
|
||||
)
|
||||
security_header_csp_value: str = Field(
|
||||
default=(
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline';"
|
||||
@@ -238,14 +268,18 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# X-Frame-Options - Prevents clickjacking
|
||||
security_header_x_frame_options_enabled: bool = Field(default=True, description="Enable X-Frame-Options header.")
|
||||
security_header_x_frame_options_enabled: bool = Field(
|
||||
default=True, description="Enable X-Frame-Options header."
|
||||
)
|
||||
security_header_x_frame_options_value: str = Field(
|
||||
default="DENY", description="X-Frame-Options header value. Options: DENY, SAMEORIGIN, or ALLOW-FROM uri"
|
||||
default="DENY",
|
||||
description="X-Frame-Options header value. Options: DENY, SAMEORIGIN, or ALLOW-FROM uri",
|
||||
)
|
||||
|
||||
# X-Content-Type-Options - Prevents MIME sniffing
|
||||
security_header_x_content_type_options_enabled: bool = Field(
|
||||
default=True, description="Enable X-Content-Type-Options header (always set to 'nosniff')."
|
||||
default=True,
|
||||
description="Enable X-Content-Type-Options header (always set to 'nosniff').",
|
||||
)
|
||||
|
||||
# Audit Logging Configuration (see SECURITY_AUDIT.md – Infrastructure Security)
|
||||
@@ -301,7 +335,9 @@ class Settings(BaseSettings):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str) and len(value) >= 2:
|
||||
if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
|
||||
if (value[0] == '"' and value[-1] == '"') or (
|
||||
value[0] == "'" and value[-1] == "'"
|
||||
):
|
||||
data[key] = value[1:-1]
|
||||
return data
|
||||
|
||||
@@ -336,7 +372,9 @@ class Settings(BaseSettings):
|
||||
return env_build_date
|
||||
|
||||
# Then try to get build date from BUILD_DATE file
|
||||
build_date_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE")
|
||||
build_date_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE"
|
||||
)
|
||||
if os.path.exists(build_date_file):
|
||||
with open(build_date_file, "r") as f:
|
||||
return f.read().strip()
|
||||
@@ -353,7 +391,9 @@ class Settings(BaseSettings):
|
||||
return env_version
|
||||
|
||||
# Then try to get version from VERSION file
|
||||
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
|
||||
version_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "VERSION"
|
||||
)
|
||||
if os.path.exists(version_file):
|
||||
with open(version_file, "r") as f:
|
||||
return f.read().strip()
|
||||
@@ -370,7 +410,9 @@ class Settings(BaseSettings):
|
||||
return env_sha
|
||||
|
||||
# Then try to get from GIT_SHA file
|
||||
git_sha_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "GIT_SHA")
|
||||
git_sha_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "GIT_SHA"
|
||||
)
|
||||
if os.path.exists(git_sha_file):
|
||||
with open(git_sha_file, "r") as f:
|
||||
return f.read().strip()
|
||||
@@ -381,7 +423,9 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def runtime_info(self) -> str:
|
||||
"""Get runtime information from file."""
|
||||
runtime_info_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "RUNTIME_INFO")
|
||||
runtime_info_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "RUNTIME_INFO"
|
||||
)
|
||||
if os.path.exists(runtime_info_file):
|
||||
with open(runtime_info_file, "r") as f:
|
||||
return f.read().strip()
|
||||
|
||||
Reference in New Issue
Block a user