fix: address self-review findings on PR #2757

* Gate SECURE_PROXY_SSL_HEADER on FORWARDED_ALLOW_IPS
  (anthias_django/settings.py): without the gate, a client on a
  plain-HTTP deploy could send `X-Forwarded-Proto: https` and flip
  `request.is_secure()`. Django reads the header from META directly,
  independent of uvicorn's --proxy-headers flag, so the previous
  unconditional setting was actually exploitable in non-SSL mode
  (secure-cookied sessions would drop on the next plain-HTTP request,
  redirects would point at https:// URLs that don't exist).

  Verified live: non-SSL → SECURE_PROXY_SSL_HEADER is None and
  is_secure() with spoofed XFP=https returns False; SSL via Caddy
  override → header is set and is_secure() returns True.

* Replace the isfile() pre-check + open() in anthias_assets and
  static_with_mime with a try/except FileNotFoundError around open()
  (anthias_app/views_files.py). Eliminates a (tiny but real) TOCTOU
  window between the stat and the open. IsADirectoryError handled
  too, since `realpath('/dir/')` resolves to the directory and open()
  would otherwise 500.

* Comment FORWARDED_ALLOW_IPS=* assumption in bin/enable_ssl.sh: the
  wildcard is only safe because the override drops anthias-server's
  external port mapping, so any future edit that re-adds a host:port
  publication has to either tighten the wildcard to Caddy's IP/CIDR
  or unset it.

* Replace ANSI-C escape sequences in the Caddyfile generator with
  plain multi-line strings. `read -r -d ''` was the first attempt
  but it strips trailing newlines, which collapsed `auto_https off`
  onto the same line as `}` in cert mode. Multi-line literals with
  echo "$VAR" are unambiguous and Caddy validates all three modes
  cleanly again.

* Add a docker-volume cleanup hint to bin/disable_ssl.sh: Caddy's
  local CA persists in anthias_anthias-caddy-data so an enable →
  disable → enable cycle reuses the same CA (intentional — browsers
  that trusted it stay trusted), and operators who want a fresh CA
  now have the exact `docker volume rm` command in the script's
  output.

15 view tests still pass; default + SSL Caddyfiles still validate;
default + SSL endpoints still return 200 / 301 / 101 in smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Petersson
2026-04-27 11:09:13 +00:00
parent bbe3153049
commit 07b784b92b
4 changed files with 40 additions and 14 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -24,3 +24,10 @@ sudo -E docker compose \
echo
echo "SSL disabled. Anthias is now reachable at http://<your IP> (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"

View File

@@ -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 <<EOF
# Generated by bin/enable_ssl.sh — delete this file (and re-run