From 7d6d7c6d27d586db0e05e2fc3bf971d46c03b82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Sat, 15 Aug 2026 19:30:02 +0200 Subject: [PATCH] fix: secure Django secret generation Prevent the signing key from inheriting permissive umasks or being replaced by competing replicas. Let the migration owner create and repair it while dependent services wait. --- .github/workflows/container-test.yml | 5 ++ start | 75 ++++++++++++++++++++++++---- tests/test-runtime-secrets | 42 ++++++++++++++++ 3 files changed, 112 insertions(+), 10 deletions(-) create mode 100755 tests/test-runtime-secrets diff --git a/.github/workflows/container-test.yml b/.github/workflows/container-test.yml index 524943db8..1573b9bf7 100644 --- a/.github/workflows/container-test.yml +++ b/.github/workflows/container-test.yml @@ -111,6 +111,11 @@ jobs: run: ./test-saml "$WEBLATE_TEST" env: WEBLATE_TEST: ${{ inputs.test }} + - name: Verify Django secret permissions + working-directory: docker-compose + run: ../tests/test-runtime-secrets "$WEBLATE_TEST" + env: + WEBLATE_TEST: ${{ inputs.test }} - name: Test commands working-directory: docker-compose run: ./test-commands diff --git a/start b/start index 857bbfb70..bf5b74aed 100755 --- a/start +++ b/start @@ -52,6 +52,11 @@ runserver_has_web_service() { [[ -z $weblate_service || $weblate_service == "web" ]] } +service_runs_migrations() { + local weblate_service=${WEBLATE_SERVICE:-} + [[ -z $weblate_service || $weblate_service == "celery-beat" ]] +} + EARLY_NGINX_PID_FILE=/run/nginx-early.pid EARLY_NGINX_CONF_FILE=/tmp/nginx/nginx-early.conf @@ -119,11 +124,56 @@ prepare_ssh_keys() { } ensure_django_secret() { - if [[ ! -s /app/data/secret ]]; then - echo "Generating Django secret..." - # https://github.com/django/django/blob/1.10.2/django/utils/crypto.py#L54-L56 - /app/venv/bin/python -c "from django.utils.crypto import get_random_string; print(get_random_string(50))" > /app/data/secret - fi + local secret_file=/app/data/secret + local secret_tmp + + ( + umask 077 + exec 9> /app/data/.django-secret.lock + flock -x 9 + + if [[ ! -s $secret_file ]]; then + echo "Generating Django secret..." + secret_tmp=$(mktemp /app/data/.django-secret.XXXXXX) + trap 'rm -f "$secret_tmp"' EXIT + # https://github.com/django/django/blob/1.10.2/django/utils/crypto.py#L54-L56 + /app/venv/bin/python -c "from django.utils.crypto import get_random_string; print(get_random_string(50))" > "$secret_tmp" + if [[ ! -s $secret_tmp ]]; then + echo "Failed to generate a non-empty Django secret." >&2 + exit 1 + fi + chmod 600 "$secret_tmp" + mv -f -- "$secret_tmp" "$secret_file" + secret_tmp= + trap - EXIT + fi + + # Repair secrets created by older container versions or copied into the volume. + chmod 600 "$secret_file" + ) +} + +django_secret_ready() { + [[ -s /app/data/secret && -r /app/data/secret ]] || return 1 + [[ $(stat -c %a /app/data/secret 2> /dev/null) == "600" ]] +} + +wait_for_django_secret() { + local max_retries=30 + local timeout=0 + + until django_secret_ready; do + if ((timeout >= max_retries)); then + echo "Django secret was not created by the migration service within $max_retries seconds." >&2 + echo "Ensure exactly one container runs migrations and that /app/data is shared with the same uid." >&2 + exit 1 + fi + if ((timeout == 0)); then + echo "Waiting for the migration service to create the Django secret..." + fi + sleep 1 + ((timeout += 1)) + done } ensure_saml_certificate() { @@ -146,7 +196,11 @@ ensure_saml_certificate() { prepare_runtime_files() { prepare_ssh_keys ensure_data_volume_writable - ensure_django_secret + if service_runs_migrations; then + ensure_django_secret + else + wait_for_django_secret + fi ensure_saml_certificate } @@ -333,7 +387,11 @@ check_postgres_version() { } prepare_supervisor_services() { - DO_MIGRATE=1 + if service_runs_migrations; then + DO_MIGRATE=1 + else + DO_MIGRATE=0 + fi SUPERVISOR_CONF=/run/supervisor.conf.d/ # Select which services to run. @@ -342,9 +400,6 @@ prepare_supervisor_services() { rm -f "$SUPERVISOR_CONF"/* if [[ -n $WEBLATE_SERVICE ]]; then - if [[ $WEBLATE_SERVICE != "celery-beat" ]]; then - DO_MIGRATE=0 - fi ln -s "/etc/supervisor/conf.d/$WEBLATE_SERVICE.conf" "$SUPERVISOR_CONF" return 0 fi diff --git a/tests/test-runtime-secrets b/tests/test-runtime-secrets new file mode 100755 index 000000000..d6a52eab2 --- /dev/null +++ b/tests/test-runtime-secrets @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +fail() { + echo "$1" >&2 + exit 1 +} + +secret_mode() { + docker compose exec -T weblate stat -c %a /app/data/secret +} + +secret_checksum() { + docker compose exec -T weblate sha256sum /app/data/secret +} + +MODE=$(secret_mode) +[ "$MODE" = "600" ] || fail "Django secret has mode $MODE instead of 600" + +CHECKSUM=$(secret_checksum) +docker compose exec -T weblate chmod 0644 /app/data/secret + +if [ "$1" = "split" ]; then + MIGRATION_SERVICE=weblate-celery-beat +else + MIGRATION_SERVICE=weblate +fi + +# Run the entrypoint as the migration owner to exercise legacy permission repair. +docker compose run --rm --no-deps "$MIGRATION_SERVICE" list_versions > /dev/null + +MODE=$(secret_mode) +[ "$MODE" = "600" ] || fail "Django secret mode was not repaired: $MODE" +[ "$(secret_checksum)" = "$CHECKSUM" ] || fail "Django secret changed while repairing permissions" + +if [ "$1" = "split" ]; then + GENERATIONS=$(docker compose logs --no-color 2>&1 | grep -c "Generating Django secret..." || true) + [ "$GENERATIONS" -eq 1 ] || fail "Expected one Django secret generation, found $GENERATIONS" +elif [ "$1" = "saml" ]; then + MODE=$(docker compose exec -T weblate stat -c %a /app/data/ssl/saml.key) + [ "$MODE" = "600" ] || fail "SAML private key has mode $MODE instead of 600" +fi