From 41c63980f0df975b7ae7fe33b2545d881c94a5ef Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Mon, 27 Apr 2026 14:23:13 +0200 Subject: [PATCH] Harden secret key configuration --- README.md | 29 ++++++++++++++- app/__init__.py | 3 +- app/config.py | 47 +++++++++++++++++++++++- env | 2 +- tests/test_config.py | 87 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 163 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c409748..d69c8a8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ pip install -e ".[dev]" ```bash export FLASK_APP=wsgi.py +export APP_ENV=development export MAX_UPLOAD_SIZE_MB=100 flask run --debug ``` @@ -73,7 +74,7 @@ docker build -t webfortilog . ### Run ```bash -docker run --rm -p 8000:8000 -e MAX_UPLOAD_SIZE_MB=100 webfortilog +docker run --rm -p 8000:8000 -e APP_ENV=development -e MAX_UPLOAD_SIZE_MB=100 webfortilog ``` Open `http://127.0.0.1:8000`. @@ -89,12 +90,16 @@ docker compose up --build web Compose settings are stored in `env`. Update that file to change values such as: - `SECRET_KEY` +- `APP_ENV` - `MAX_UPLOAD_SIZE_MB` - `OUTPUT_DIRECTORY` - `OUTPUT_RETENTION_HOURS` - `CLEANUP_ON_STARTUP` - `CLEANUP_AFTER_DOWNLOAD` +For local Docker Compose usage, `APP_ENV=development` allows an internal development-only fallback secret key. +For production-like environments, set a strong `SECRET_KEY` explicitly. + ### Run the test suite in a container ```bash @@ -137,6 +142,28 @@ curl -X POST http://127.0.0.1:5000/convert \ - Default upload limit is 100 MiB - Set `MAX_UPLOAD_SIZE_MB` to configure the upload limit in megabytes - `MAX_CONTENT_LENGTH` is also supported as a lower-level byte-based override +- `SECRET_KEY` is required in production-like environments and must not use placeholder values such as `change-me` +- Development-only fallback secret key behavior is enabled only when `APP_ENV=development` or `FLASK_ENV=development` - `OUTPUT_RETENTION_HOURS` controls how long generated output files are kept - `CLEANUP_ON_STARTUP=true` removes expired generated files when the app starts - `CLEANUP_AFTER_DOWNLOAD=true` deletes a result only after the response finishes sending + +## Secure configuration example + +### Production-like environment + +```bash +python3 - <<'PY' +import secrets +print(secrets.token_urlsafe(48)) +PY +``` + +Use the generated value as `SECRET_KEY`, for example: + +```bash +docker run --rm -p 8000:8000 \ + -e SECRET_KEY='replace-with-a-long-random-secret' \ + -e MAX_UPLOAD_SIZE_MB=100 \ + webfortilog +``` diff --git a/app/__init__.py b/app/__init__.py index 9ce12cb..95f2be5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from flask import Flask, flash, render_template from werkzeug.exceptions import RequestEntityTooLarge -from app.config import Config +from app.config import Config, validate_secret_key from app.routes import main_blueprint from app.services.storage import cleanup_expired_outputs @@ -21,6 +21,7 @@ def create_app(config_class: type[Config] = Config) -> Flask: """Application factory used by Flask and Gunicorn.""" app = Flask(__name__, instance_relative_config=True) app.config.from_object(config_class) + validate_secret_key(app.config["SECRET_KEY"]) output_dir = Path(app.config["OUTPUT_DIRECTORY"]) if not output_dir.is_absolute(): diff --git a/app/config.py b/app/config.py index 6d84372..979b646 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,15 @@ import os from pathlib import Path +DEVELOPMENT_SECRET_KEY = "dev-secret-key-change-me" +UNSAFE_SECRET_KEYS = { + "", + "change-me", + "dev-secret-key-change-me", + "secret", + "default", +} + def _get_bool_setting(name: str, default: bool) -> bool: """Parse conventional boolean environment values.""" @@ -23,10 +32,46 @@ def _get_max_content_length() -> int: return 100 * 1024 * 1024 +def _get_app_env() -> str: + """Resolve the effective application environment.""" + return ( + os.environ.get("APP_ENV") + or os.environ.get("FLASK_ENV") + or "production" + ).strip().lower() + + +def _is_development_env() -> bool: + """Return whether the app is explicitly running in development mode.""" + return _get_app_env() == "development" + + +def _get_secret_key() -> str: + """Resolve the secret key with a development-only fallback.""" + secret_key = os.environ.get("SECRET_KEY", "").strip() + if secret_key: + return secret_key + if _is_development_env(): + return DEVELOPMENT_SECRET_KEY + return "" + + +def validate_secret_key(secret_key: str) -> None: + """Fail fast when a production-like environment uses an unsafe secret key.""" + normalized = secret_key.strip() + if _is_development_env(): + return + if normalized.lower() in UNSAFE_SECRET_KEYS: + raise RuntimeError( + "SECRET_KEY is missing or unsafe for a production-like environment. " + "Set SECRET_KEY to a long random value, or use APP_ENV=development only for local development." + ) + + class Config: """Default configuration for local and container usage.""" - SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me") + SECRET_KEY = _get_secret_key() # Default to 100 MiB so larger WAF exports can be processed without tuning. MAX_CONTENT_LENGTH = _get_max_content_length() PREVIEW_RECORD_LIMIT = int(os.environ.get("PREVIEW_RECORD_LIMIT", 5)) diff --git a/env b/env index 6511618..ac10f34 100644 --- a/env +++ b/env @@ -1,4 +1,4 @@ -SECRET_KEY=change-me +APP_ENV=development MAX_UPLOAD_SIZE_MB=120 OUTPUT_DIRECTORY=/app/instance/outputs OUTPUT_RETENTION_HOURS=24 diff --git a/tests/test_config.py b/tests/test_config.py index 9c43692..111c781 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,12 @@ -from app.config import _get_max_content_length +import pytest + +from app import create_app +from app.config import ( + DEVELOPMENT_SECRET_KEY, + _get_max_content_length, + _get_secret_key, + validate_secret_key, +) def test_max_upload_size_mb_environment_variable(monkeypatch): @@ -13,3 +21,80 @@ def test_max_content_length_environment_variable_is_supported(monkeypatch): monkeypatch.setenv("MAX_CONTENT_LENGTH", "2048") assert _get_max_content_length() == 2048 + + +def test_secret_key_uses_development_fallback(monkeypatch): + monkeypatch.setenv("APP_ENV", "development") + monkeypatch.delenv("FLASK_ENV", raising=False) + monkeypatch.delenv("SECRET_KEY", raising=False) + + assert _get_secret_key() == DEVELOPMENT_SECRET_KEY + + +def test_secret_key_is_required_outside_development(monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("FLASK_ENV", raising=False) + monkeypatch.delenv("SECRET_KEY", raising=False) + + assert _get_secret_key() == "" + + +def test_validate_secret_key_rejects_unsafe_value_outside_development(monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("FLASK_ENV", raising=False) + + with pytest.raises(RuntimeError, match="SECRET_KEY is missing or unsafe"): + validate_secret_key("change-me") + + +def test_create_app_allows_development_without_explicit_secret_key(tmp_path, monkeypatch): + monkeypatch.setenv("APP_ENV", "development") + monkeypatch.delenv("FLASK_ENV", raising=False) + monkeypatch.delenv("SECRET_KEY", raising=False) + + class DevelopmentConfig: + SECRET_KEY = DEVELOPMENT_SECRET_KEY + MAX_CONTENT_LENGTH = 1024 + PREVIEW_RECORD_LIMIT = 5 + OUTPUT_DIRECTORY = tmp_path / "dev-outputs" + OUTPUT_RETENTION_HOURS = 24 + CLEANUP_ON_STARTUP = False + CLEANUP_AFTER_DOWNLOAD = False + + app = create_app(DevelopmentConfig) + + assert app.config["SECRET_KEY"] == DEVELOPMENT_SECRET_KEY + + +def test_create_app_rejects_unsafe_secret_key_outside_development(tmp_path, monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("FLASK_ENV", raising=False) + + class ProductionConfig: + SECRET_KEY = "change-me" + MAX_CONTENT_LENGTH = 1024 + PREVIEW_RECORD_LIMIT = 5 + OUTPUT_DIRECTORY = tmp_path / "prod-outputs" + OUTPUT_RETENTION_HOURS = 24 + CLEANUP_ON_STARTUP = False + CLEANUP_AFTER_DOWNLOAD = False + + with pytest.raises(RuntimeError, match="SECRET_KEY is missing or unsafe"): + create_app(ProductionConfig) + + +def test_create_app_rejects_missing_secret_key_outside_development(tmp_path, monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("FLASK_ENV", raising=False) + + class ProductionConfig: + SECRET_KEY = "" + MAX_CONTENT_LENGTH = 1024 + PREVIEW_RECORD_LIMIT = 5 + OUTPUT_DIRECTORY = tmp_path / "prod-outputs-missing-key" + OUTPUT_RETENTION_HOURS = 24 + CLEANUP_ON_STARTUP = False + CLEANUP_AFTER_DOWNLOAD = False + + with pytest.raises(RuntimeError, match="SECRET_KEY is missing or unsafe"): + create_app(ProductionConfig)