diff --git a/start b/start index 5f9b5ca9d..3441f1f00 100755 --- a/start +++ b/start @@ -58,6 +58,9 @@ service_runs_migrations() { } NGINX_PID_FILE=/run/nginx.pid +CUSTOM_CA_CERTS_DIR=/app/data/ssl/ca-certificates +SYSTEM_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +CUSTOM_CA_BUNDLE=/tmp/custom-ca-certificates.crt fail_dep() { >&2 echo "$1 not running!" @@ -274,6 +277,46 @@ ensure_saml_certificate() { fi } +install_custom_ca_certificates() { + # Build a combined CA bundle (system certificates plus user-provided + # ones from the data volume) in a runtime-writable location, then point + # Python (requests), OpenSSL, libcurl and git at it. The system trust + # store is not writable for the container's unprivileged user, and the + # bundle is rebuilt from scratch on every start, so stale certificates + # never linger. + local certs=() + local cert + if [[ -d $CUSTOM_CA_CERTS_DIR ]]; then + shopt -s nullglob + certs=("$CUSTOM_CA_CERTS_DIR"/*.crt) + shopt -u nullglob + fi + if ((${#certs[@]} == 0)); then + return 0 + fi + echo "Adding ${#certs[@]} custom CA certificate(s) from $CUSTOM_CA_CERTS_DIR to the runtime CA bundle..." + # Separate inputs with newlines: a source file without a trailing newline + # would otherwise glue PEM blocks together (e.g. "-----END CERTIFICATE----------BEGIN CERTIFICATE-----"), + # producing a bundle that OpenSSL/Python reject. + { + cat "$SYSTEM_CA_BUNDLE" + printf '\n' + for cert in "${certs[@]}"; do + cat "$cert" + printf '\n' + done + } > "$CUSTOM_CA_BUNDLE" + chmod 0644 "$CUSTOM_CA_BUNDLE" + export REQUESTS_CA_BUNDLE="$CUSTOM_CA_BUNDLE" + export SSL_CERT_FILE="$CUSTOM_CA_BUNDLE" + export CURL_CA_BUNDLE="$CUSTOM_CA_BUNDLE" + export GIT_SSL_CAINFO="$CUSTOM_CA_BUNDLE" + # libpq (psql) ignores SSL_CERT_FILE; without PGSSLROOTCERT the + # check_postgres_version probe rejects servers signed by a custom CA + # when POSTGRES_SSL_MODE=verify-ca/verify-full. + export PGSSLROOTCERT="$CUSTOM_CA_BUNDLE" +} + prepare_runtime_files() { prepare_ssh_keys ensure_data_volume_writable @@ -283,6 +326,7 @@ prepare_runtime_files() { wait_for_django_secret fi ensure_saml_certificate + install_custom_ca_certificates } ensure_runtime_user() { diff --git a/tests/run b/tests/run index a1fced04a..a51a07a0b 100755 --- a/tests/run +++ b/tests/run @@ -14,3 +14,6 @@ echo 'Running Docker build arguments tests' echo 'Running image signing tests' ./tests/test-sign-image-tags + +echo 'Running custom CA certificate tests' +./tests/test-custom-ca-certificates diff --git a/tests/test-custom-ca-certificates b/tests/test-custom-ca-certificates new file mode 100755 index 000000000..f70b3cd54 --- /dev/null +++ b/tests/test-custom-ca-certificates @@ -0,0 +1,85 @@ +#!/bin/bash +# Tests for install_custom_ca_certificates in the start script: the function +# is extracted verbatim and exercised against stub directories. + +set -eu + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM + +# Extract the path variables and the function itself from start. +awk '/^CUSTOM_CA_CERTS_DIR=|^SYSTEM_CA_BUNDLE=|^CUSTOM_CA_BUNDLE=/' start > "$test_dir/vars.sh" +awk '/^install_custom_ca_certificates\(\) \{/,/^\}/' start > "$test_dir/function.sh" +grep -q 'GIT_SSL_CAINFO' "$test_dir/function.sh" +grep -q 'PGSSLROOTCERT' "$test_dir/function.sh" + +# Stub "system" bundle used as the base of the combined bundle. +printf 'system-ca\n' > "$test_dir/system.crt" + +# Evaluate vars + function in a subshell per case. env -u strips inherited +# CA variables so a proxy or custom CA configured on the host cannot leak +# into the assertions. +run_case() { + env -u REQUESTS_CA_BUNDLE -u SSL_CERT_FILE -u CURL_CA_BUNDLE -u GIT_SSL_CAINFO -u PGSSLROOTCERT bash -c " + set -eu + . '$test_dir/vars.sh' + . '$test_dir/function.sh' + CUSTOM_CA_CERTS_DIR='$1' + SYSTEM_CA_BUNDLE='$test_dir/system.crt' + CUSTOM_CA_BUNDLE='$test_dir/bundle.crt' + install_custom_ca_certificates + echo \"exports: \${REQUESTS_CA_BUNDLE:-unset} \${SSL_CERT_FILE:-unset} \${CURL_CA_BUNDLE:-unset} \${GIT_SSL_CAINFO:-unset} \${PGSSLROOTCERT:-unset}\" + " | tail -n 1 +} + +fail=0 +check() { # $1 = label, $2 = expected, $3 = actual + if [ "$2" = "$3" ]; then + echo "ok: $1" + else + echo "FAIL: $1 (expected [$2], got [$3])" + fail=1 + fi +} + +# Case 1: no source directory -> no-op, no exports, no bundle. +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/no-such-dir") +check 'no dir: no exports' 'exports: unset unset unset unset unset' "$out" +check 'no dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" + +# Case 2: two certificates -> combined bundle written, exports set. +mkdir -p "$test_dir/src2" +printf 'cert-a\n' > "$test_dir/src2/internal-a.crt" +printf 'cert-b\n' > "$test_dir/src2/internal-b.crt" +touch "$test_dir/src2/ignored.pem" +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/src2") +check 'two certs: exports point at bundle' "exports: $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt $test_dir/bundle.crt" "$out" +check 'two certs: bundle = system + custom' 'system-ca cert-a cert-b' "$(tr '\n' ' ' < "$test_dir/bundle.crt" | tr -s ' ' | sed 's/ $//')" +check 'two certs: bundle is world-readable' '644' "$(stat -c '%a' "$test_dir/bundle.crt")" + +# Case 4: a certificate without a trailing newline must not glue PEM blocks. +mkdir -p "$test_dir/src4" +printf 'cert-no-newline' > "$test_dir/src4/internal-a.crt" +printf 'cert-after\n' > "$test_dir/src4/internal-b.crt" +rm -f "$test_dir/bundle.crt" +run_case "$test_dir/src4" > /dev/null +check 'no trailing newline: first cert on its own line' '1' "$( + grep -xc 'cert-no-newline' "$test_dir/bundle.crt" + true +)" +check 'no trailing newline: second cert on its own line' '1' "$( + grep -xc 'cert-after' "$test_dir/bundle.crt" + true +)" +check 'no trailing newline: no glued content' '0' "$(grep -c 'cert-no-newlinecert-after' "$test_dir/bundle.crt" || true)" + +# Case 3: empty source directory -> no-op. +mkdir -p "$test_dir/src3" +rm -f "$test_dir/bundle.crt" +out=$(run_case "$test_dir/src3") +check 'empty dir: no exports' 'exports: unset unset unset unset unset' "$out" +check 'empty dir: no bundle written' 'missing' "$(cat "$test_dir/bundle.crt" 2> /dev/null || echo missing)" + +exit "$fail"