Harden runtime configuration and container security defaults

This commit is contained in:
Alfredo Di Stasio
2026-03-10 13:06:12 +01:00
parent 3d795991fe
commit b39c6ced3a
14 changed files with 154 additions and 18 deletions

View File

@ -1,10 +1,14 @@
# Django # Django
DJANGO_SETTINGS_MODULE=config.settings.development DJANGO_SETTINGS_MODULE=config.settings.development
DJANGO_ENV=development
# Required to be a strong, unique value when DJANGO_DEBUG=0.
DJANGO_SECRET_KEY=change-me-in-production DJANGO_SECRET_KEY=change-me-in-production
DJANGO_DEBUG=1 DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1
DJANGO_TIME_ZONE=UTC DJANGO_TIME_ZONE=UTC
DJANGO_LOG_LEVEL=INFO
DJANGO_LOG_SQL=0
DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@example.com DJANGO_SUPERUSER_EMAIL=admin@example.com
DJANGO_SUPERUSER_PASSWORD=adminpass DJANGO_SUPERUSER_PASSWORD=adminpass
@ -29,6 +33,10 @@ AUTO_COLLECTSTATIC=1
AUTO_BUILD_TAILWIND=1 AUTO_BUILD_TAILWIND=1
GUNICORN_WORKERS=3 GUNICORN_WORKERS=3
# Production-minded security toggles
DJANGO_SECURE_SSL_REDIRECT=1
DJANGO_SECURE_HSTS_SECONDS=31536000
# Providers / ingestion # Providers / ingestion
PROVIDER_BACKEND=demo PROVIDER_BACKEND=demo
PROVIDER_NAMESPACE_DEMO=mvp_demo PROVIDER_NAMESPACE_DEMO=mvp_demo

View File

@ -24,7 +24,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \ VIRTUAL_ENV=/opt/venv \
PATH="/opt/venv/bin:${PATH}" PATH="/opt/venv/bin:/home/app/.local/bin:${PATH}" \
APP_USER=app \
APP_UID=10001 \
APP_GID=10001
WORKDIR /app WORKDIR /app
@ -32,6 +35,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 postgresql-client curl nodejs npm \ && apt-get install -y --no-install-recommends libpq5 postgresql-client curl nodejs npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --gid "${APP_GID}" "${APP_USER}" \
&& useradd --uid "${APP_UID}" --gid "${APP_GID}" --create-home --shell /usr/sbin/nologin "${APP_USER}"
RUN printf '%s\n' 'export PATH="/opt/venv/bin:/home/app/.local/bin:$PATH"' > /etc/profile.d/hoopscout-path.sh
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY . /app COPY . /app
@ -39,7 +46,9 @@ RUN if [ -f package.json ]; then npm install --no-audit --no-fund; fi
RUN if [ -f package.json ]; then npm run build; fi RUN if [ -f package.json ]; then npm run build; fi
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
RUN mkdir -p /app/staticfiles /app/media /app/runtime RUN mkdir -p /app/staticfiles /app/media /app/runtime /app/node_modules /app/static/vendor \
&& chown -R "${APP_UID}:${APP_GID}" /app /opt/venv
USER ${APP_UID}:${APP_GID}
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

View File

@ -95,6 +95,7 @@ docker compose exec web python manage.py createsuperuser
- `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness. - `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness.
- `web` service also builds Tailwind CSS before `collectstatic` when `AUTO_BUILD_TAILWIND=1`. - `web` service also builds Tailwind CSS before `collectstatic` when `AUTO_BUILD_TAILWIND=1`.
- `web`, `celery_worker`, `celery_beat`, and `tailwind` run as a non-root user inside the image.
- `celery_worker` executes background sync work. - `celery_worker` executes background sync work.
- `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider). - `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider).
- `tailwind` service runs watch mode for development (`npm run dev`). - `tailwind` service runs watch mode for development (`npm run dev`).
@ -156,6 +157,30 @@ docker compose up tailwind
``` ```
Source CSS lives in `static/src/tailwind.css` and compiles to `static/css/main.css`. Source CSS lives in `static/src/tailwind.css` and compiles to `static/css/main.css`.
HTMX is served from local static assets (`static/vendor/htmx.min.js`) instead of a CDN dependency.
## Production Configuration
Use production settings in deployed environments:
```bash
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_DEBUG=0
DJANGO_ENV=production
```
When `DJANGO_DEBUG=0`, startup fails fast unless:
- `DJANGO_SECRET_KEY` is a real non-default value
- `DJANGO_ALLOWED_HOSTS` is set
- `DJANGO_CSRF_TRUSTED_ORIGINS` is set (for production settings)
Production settings enable hardened defaults such as:
- secure cookies
- HSTS
- security headers
- `ManifestStaticFilesStorage` for static asset integrity/versioning
## Superuser and Auth ## Superuser and Auth

View File

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
import os import os
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@ -16,6 +17,7 @@ def env_list(key: str, default: str = "") -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
DJANGO_ENV = os.getenv("DJANGO_ENV", "development").strip().lower()
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure-development-secret") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure-development-secret")
DEBUG = env_bool("DJANGO_DEBUG", False) DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
@ -23,6 +25,11 @@ CSRF_TRUSTED_ORIGINS = env_list(
"DJANGO_CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1" "DJANGO_CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1"
) )
if not DEBUG and SECRET_KEY in {"", "insecure-development-secret", "change-me-in-production"}:
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a strong value when DEBUG=0.")
if not DEBUG and not ALLOWED_HOSTS:
raise ImproperlyConfigured("DJANGO_ALLOWED_HOSTS must not be empty when DEBUG=0.")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@ -96,12 +103,12 @@ TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC")
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_URL = "/static/" STATIC_URL = os.getenv("DJANGO_STATIC_URL", "/static/")
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = Path(os.getenv("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")))
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "/media/" MEDIA_URL = os.getenv("DJANGO_MEDIA_URL", "/media/")
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = Path(os.getenv("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media")))
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@ -141,6 +148,35 @@ PROVIDER_BALLDONTLIE_SEASONS = [
if value.strip().isdigit() if value.strip().isdigit()
] ]
LOG_LEVEL = os.getenv("DJANGO_LOG_LEVEL", "INFO").upper()
LOG_SQL = env_bool("DJANGO_LOG_SQL", False)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
}
},
"root": {
"handlers": ["console"],
"level": LOG_LEVEL,
},
"loggers": {
"django.db.backends": {
"handlers": ["console"],
"level": "DEBUG" if LOG_SQL else "WARNING",
"propagate": False,
},
},
}
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.ReadOnlyOrDeny", "apps.api.permissions.ReadOnlyOrDeny",

View File

@ -1,3 +1,6 @@
from .base import * # noqa: F403,F401 from .base import * # noqa: F403,F401
DEBUG = True DEBUG = True
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False

View File

@ -1,13 +1,33 @@
from .base import * # noqa: F403,F401 from .base import * # noqa: F403,F401
import os import os
from django.core.exceptions import ImproperlyConfigured
DEBUG = False DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_SSL_REDIRECT = os.getenv("DJANGO_SECURE_SSL_REDIRECT", "1") == "1"
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
SECURE_HSTS_SECONDS = int(os.getenv("DJANGO_SECURE_HSTS_SECONDS", "31536000")) SECURE_HSTS_SECONDS = int(os.getenv("DJANGO_SECURE_HSTS_SECONDS", "31536000"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True SECURE_HSTS_PRELOAD = True
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = os.getenv("DJANGO_SESSION_COOKIE_SAMESITE", "Lax")
CSRF_COOKIE_SAMESITE = os.getenv("DJANGO_CSRF_COOKIE_SAMESITE", "Lax")
if not CSRF_TRUSTED_ORIGINS: # noqa: F405
raise ImproperlyConfigured("DJANGO_CSRF_TRUSTED_ORIGINS must be set for production.")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}

View File

@ -10,11 +10,16 @@ services:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- static_data:/var/www/static:ro - static_data:/var/www/static:ro
- media_data:/var/www/media:ro - media_data:/var/www/media:ro
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/ || exit 1"] test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/ || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s
restart: unless-stopped restart: unless-stopped
web: web:
@ -28,7 +33,7 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --access-logfile - --error-logfile -
volumes: volumes:
- .:/app - .:/app
- node_modules_data:/app/node_modules - node_modules_data:/app/node_modules
@ -42,6 +47,7 @@ services:
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 8 retries: 8
start_period: 20s
restart: unless-stopped restart: unless-stopped
tailwind: tailwind:
@ -76,6 +82,7 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 30s
restart: unless-stopped restart: unless-stopped
celery_beat: celery_beat:
@ -98,12 +105,15 @@ services:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 20s
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
env_file: environment:
- .env POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:

View File

@ -8,6 +8,11 @@ done
echo "PostgreSQL is available." echo "PostgreSQL is available."
if [ "${DJANGO_SETTINGS_MODULE:-}" = "config.settings.production" ] && [ "$1" = "gunicorn" ]; then
echo "Running Django deployment checks..."
python manage.py check --deploy --fail-level WARNING
fi
if [ "${AUTO_APPLY_MIGRATIONS:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then if [ "${AUTO_APPLY_MIGRATIONS:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
echo "Applying database migrations..." echo "Applying database migrations..."
python manage.py migrate --noinput python manage.py migrate --noinput
@ -15,11 +20,12 @@ fi
if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
if [ "${AUTO_BUILD_TAILWIND:-1}" = "1" ] && [ -f /app/package.json ]; then if [ "${AUTO_BUILD_TAILWIND:-1}" = "1" ] && [ -f /app/package.json ]; then
if [ -x /app/node_modules/.bin/tailwindcss ]; then
echo "Building Tailwind assets..." echo "Building Tailwind assets..."
if [ ! -d /app/node_modules ]; then
npm install --no-audit --no-fund
fi
npm run build npm run build
else
echo "Tailwind dependencies missing; skipping AUTO_BUILD_TAILWIND."
fi
fi fi
echo "Collecting static files..." echo "Collecting static files..."

View File

@ -19,16 +19,21 @@ http {
server { server {
listen 80; listen 80;
server_name _; server_name _;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "same-origin" always;
location /static/ { location /static/ {
alias /var/www/static/; alias /var/www/static/;
expires 30d; expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
access_log off; access_log off;
} }
location /media/ { location /media/ {
alias /var/www/media/; alias /var/www/media/;
expires 30d; expires 7d;
add_header Cache-Control "public, max-age=604800";
access_log off; access_log off;
} }

8
package-lock.json generated
View File

@ -7,6 +7,9 @@
"": { "": {
"name": "hoopscout-frontend", "name": "hoopscout-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": {
"htmx.org": "^1.9.12"
},
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
} }
@ -275,6 +278,11 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,

View File

@ -4,8 +4,12 @@
"private": true, "private": true,
"description": "Tailwind pipeline for HoopScout Django templates", "description": "Tailwind pipeline for HoopScout Django templates",
"scripts": { "scripts": {
"build": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify", "build:vendor": "mkdir -p ./static/vendor && cp ./node_modules/htmx.org/dist/htmx.min.js ./static/vendor/htmx.min.js",
"dev": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch" "build": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify",
"dev": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch"
},
"dependencies": {
"htmx.org": "^1.9.12"
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"

View File

@ -1,3 +1,4 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = config.settings.development DJANGO_SETTINGS_MODULE = config.settings.development
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
cache_dir = /tmp/.pytest_cache

1
static/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}HoopScout{% endblock %}</title> <title>{% block title %}HoopScout{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}"> <link rel="stylesheet" href="{% static 'css/main.css' %}">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script> <script src="{% static 'vendor/htmx.min.js' %}" defer></script>
</head> </head>
<body class="min-h-full bg-slate-100 text-slate-900"> <body class="min-h-full bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white"> <header class="border-b border-slate-200 bg-white">