diff --git a/anthias_app/views_files.py b/anthias_app/views_files.py index 078a5eb4..d72c5992 100644 --- a/anthias_app/views_files.py +++ b/anthias_app/views_files.py @@ -57,13 +57,16 @@ def require_client_in(*cidrs): @require_GET @require_client_in(DOCKER_BRIDGE_CIDR) def anthias_assets(request, filename): + # Trailing os.sep on `base` is required so e.g. + # '/data/anthias_assets_evil/...' doesn't slip past startswith(). base = os.path.realpath(ANTHIAS_ASSETS_ROOT) + os.sep target = os.path.realpath(os.path.join(base, filename)) if not target.startswith(base): raise Http404 - if not os.path.isfile(target): + try: + return FileResponse(open(target, 'rb')) + except (FileNotFoundError, IsADirectoryError): raise Http404 - return FileResponse(open(target, 'rb')) @require_GET @@ -73,12 +76,13 @@ def static_with_mime(request, filename): target = os.path.realpath(os.path.join(base, filename)) if not target.startswith(base): raise Http404 - if not os.path.isfile(target): - raise Http404 content_type = request.GET.get('mime') or ( mimetypes.guess_type(target)[0] or 'application/octet-stream' ) - return FileResponse(open(target, 'rb'), content_type=content_type) + try: + return FileResponse(open(target, 'rb'), content_type=content_type) + except (FileNotFoundError, IsADirectoryError): + raise Http404 @require_GET diff --git a/anthias_django/settings.py b/anthias_django/settings.py index f26cf628..8a72d3b6 100644 --- a/anthias_django/settings.py +++ b/anthias_django/settings.py @@ -186,10 +186,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = None FILE_UPLOAD_MAX_MEMORY_SIZE = 26_214_400 # Trust X-Forwarded-Proto from a TLS-terminating proxy (the Caddy -# sidecar that bin/enable_ssl.sh installs). uvicorn enforces who is -# allowed to set the header via FORWARDED_ALLOW_IPS — without that env -# var a malicious client cannot make request.is_secure() lie. -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# sidecar that bin/enable_ssl.sh installs) only when uvicorn has been +# told to honour proxy headers via FORWARDED_ALLOW_IPS. Without the +# gate, any client could set X-Forwarded-Proto: https on a plain-HTTP +# deploy and flip request.is_secure() — secure-cookied sessions would +# then drop on the next plain-HTTP request, and redirects would point +# at https:// URLs that don't exist. +if getenv('FORWARDED_ALLOW_IPS'): + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field diff --git a/bin/disable_ssl.sh b/bin/disable_ssl.sh index d5b6bf0e..be35f756 100755 --- a/bin/disable_ssl.sh +++ b/bin/disable_ssl.sh @@ -24,3 +24,10 @@ sudo -E docker compose \ echo echo "SSL disabled. Anthias is now reachable at http:// (port 80)." +echo +echo "Caddy's local CA + issued certs are kept in the docker volume" +echo " anthias_anthias-caddy-data" +echo "so re-enabling SSL with bin/enable_ssl.sh reuses the same CA" +echo "(browsers that already trusted it stay trusted). To wipe the CA" +echo "and start over, run:" +echo " docker volume rm anthias_anthias-caddy-data anthias_anthias-caddy-config" diff --git a/bin/enable_ssl.sh b/bin/enable_ssl.sh index d3c71dae..ed639ceb 100755 --- a/bin/enable_ssl.sh +++ b/bin/enable_ssl.sh @@ -108,7 +108,7 @@ if [[ -n "$USER_CERT" ]]; then SITE_ADDRESS="${DOMAIN:-:443}" TLS_DIRECTIVE="tls /etc/anthias/ssl/cert.pem /etc/anthias/ssl/key.pem" # Disable Caddy's ACME path; we're serving the supplied cert. - EXTRA_GLOBAL=$' auto_https off\n' + EXTRA_GLOBAL=" auto_https off" MOUNT_SSL_DIR=1 [[ -z "$DOMAIN" ]] && NEEDS_REDIRECT_BLOCK=1 elif [[ -n "$DOMAIN" ]]; then @@ -117,9 +117,11 @@ elif [[ -n "$DOMAIN" ]]; then # Empty TLS directive — Caddy auto-manages via auto_https when the # site address is a hostname. Caddy also auto-creates a :80→:443 # redirect for hostname sites, so no explicit redir block needed. - [[ -n "$EMAIL" ]] && EXTRA_GLOBAL+=" email $EMAIL"$'\n' + EXTRA_GLOBAL="" + [[ -n "$EMAIL" ]] && EXTRA_GLOBAL+=" email $EMAIL" if [[ "$STAGING" == "1" ]]; then - EXTRA_GLOBAL+=" acme_ca https://acme-staging-v02.api.letsencrypt.org/directory"$'\n' + [[ -n "$EXTRA_GLOBAL" ]] && EXTRA_GLOBAL+=$'\n' + EXTRA_GLOBAL+=" acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" fi else echo "Caddy will issue a cert from its internal local CA (browsers will warn)." @@ -128,7 +130,9 @@ else # listener can serve any IP / hostname the device is reached on. # Safe without an `ask` endpoint because it's the local CA, not a # public one — there's no rate-limited issuer to abuse. - TLS_DIRECTIVE=$'tls internal {\n on_demand\n }' + TLS_DIRECTIVE="tls internal { + on_demand + }" NEEDS_REDIRECT_BLOCK=1 fi @@ -136,7 +140,7 @@ fi { echo "{" echo " admin off" - [[ -n "$EXTRA_GLOBAL" ]] && printf '%s' "$EXTRA_GLOBAL" + [[ -n "$EXTRA_GLOBAL" ]] && echo "$EXTRA_GLOBAL" echo "}" echo if [[ "$NEEDS_REDIRECT_BLOCK" == "1" ]]; then @@ -163,6 +167,13 @@ BODY # port 80 mapping is removed and all external traffic must enter # through Caddy. Inter-container traffic still reaches # anthias-server:8080 over the Docker network. +# +# FORWARDED_ALLOW_IPS=* on anthias-server is only safe BECAUSE this +# override drops the external port mapping above — only Caddy and +# other Docker-network containers can reach anthias-server. If you +# re-add a host:port mapping for anthias-server, tighten this to the +# Caddy container's IP/CIDR or back to unset, otherwise any client +# could spoof X-Forwarded-* through to uvicorn. { cat <