Merge branch 'feature/harden-secret-key-config' into develop
This commit is contained in:
29
README.md
29
README.md
@@ -42,6 +42,7 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
export FLASK_APP=wsgi.py
|
export FLASK_APP=wsgi.py
|
||||||
|
export APP_ENV=development
|
||||||
export MAX_UPLOAD_SIZE_MB=100
|
export MAX_UPLOAD_SIZE_MB=100
|
||||||
flask run --debug
|
flask run --debug
|
||||||
```
|
```
|
||||||
@@ -73,7 +74,7 @@ docker build -t webfortilog .
|
|||||||
### Run
|
### Run
|
||||||
|
|
||||||
```bash
|
```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`.
|
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:
|
Compose settings are stored in `env`. Update that file to change values such as:
|
||||||
|
|
||||||
- `SECRET_KEY`
|
- `SECRET_KEY`
|
||||||
|
- `APP_ENV`
|
||||||
- `MAX_UPLOAD_SIZE_MB`
|
- `MAX_UPLOAD_SIZE_MB`
|
||||||
- `OUTPUT_DIRECTORY`
|
- `OUTPUT_DIRECTORY`
|
||||||
- `OUTPUT_RETENTION_HOURS`
|
- `OUTPUT_RETENTION_HOURS`
|
||||||
- `CLEANUP_ON_STARTUP`
|
- `CLEANUP_ON_STARTUP`
|
||||||
- `CLEANUP_AFTER_DOWNLOAD`
|
- `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
|
### Run the test suite in a container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -137,6 +142,28 @@ curl -X POST http://127.0.0.1:5000/convert \
|
|||||||
- Default upload limit is 100 MiB
|
- Default upload limit is 100 MiB
|
||||||
- Set `MAX_UPLOAD_SIZE_MB` to configure the upload limit in megabytes
|
- 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
|
- `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
|
- `OUTPUT_RETENTION_HOURS` controls how long generated output files are kept
|
||||||
- `CLEANUP_ON_STARTUP=true` removes expired generated files when the app starts
|
- `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
|
- `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
|
||||||
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
from flask import Flask, flash, render_template
|
from flask import Flask, flash, render_template
|
||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
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.routes import main_blueprint
|
||||||
from app.services.storage import cleanup_expired_outputs
|
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."""
|
"""Application factory used by Flask and Gunicorn."""
|
||||||
app = Flask(__name__, instance_relative_config=True)
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
validate_secret_key(app.config["SECRET_KEY"])
|
||||||
|
|
||||||
output_dir = Path(app.config["OUTPUT_DIRECTORY"])
|
output_dir = Path(app.config["OUTPUT_DIRECTORY"])
|
||||||
if not output_dir.is_absolute():
|
if not output_dir.is_absolute():
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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:
|
def _get_bool_setting(name: str, default: bool) -> bool:
|
||||||
"""Parse conventional boolean environment values."""
|
"""Parse conventional boolean environment values."""
|
||||||
@@ -23,10 +32,46 @@ def _get_max_content_length() -> int:
|
|||||||
return 100 * 1024 * 1024
|
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:
|
class Config:
|
||||||
"""Default configuration for local and container usage."""
|
"""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.
|
# Default to 100 MiB so larger WAF exports can be processed without tuning.
|
||||||
MAX_CONTENT_LENGTH = _get_max_content_length()
|
MAX_CONTENT_LENGTH = _get_max_content_length()
|
||||||
PREVIEW_RECORD_LIMIT = int(os.environ.get("PREVIEW_RECORD_LIMIT", 5))
|
PREVIEW_RECORD_LIMIT = int(os.environ.get("PREVIEW_RECORD_LIMIT", 5))
|
||||||
|
|||||||
2
env
2
env
@@ -1,4 +1,4 @@
|
|||||||
SECRET_KEY=change-me
|
APP_ENV=development
|
||||||
MAX_UPLOAD_SIZE_MB=120
|
MAX_UPLOAD_SIZE_MB=120
|
||||||
OUTPUT_DIRECTORY=/app/instance/outputs
|
OUTPUT_DIRECTORY=/app/instance/outputs
|
||||||
OUTPUT_RETENTION_HOURS=24
|
OUTPUT_RETENTION_HOURS=24
|
||||||
|
|||||||
@@ -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):
|
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")
|
monkeypatch.setenv("MAX_CONTENT_LENGTH", "2048")
|
||||||
|
|
||||||
assert _get_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user