diff --git a/CLAUDE.md b/CLAUDE.md index f99bb85..5869174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,51 @@ reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument is an optional string inserted verbatim inside the Caddy site block (use it for `import authelia` or custom matchers). +The function places that block **before** `reverse_proxy` in the generated +site block — don't reorder this. `forward_auth` (what `import authelia` +expands to) is the same directive family as `reverse_proxy` internally, and +Caddy doesn't reorder repeats of the same directive within a block; it runs +them in the order they're written. `reverse_proxy` written first would +handle and terminate every request immediately, making an auth check +written after it dead code that never runs — full bypass regardless of what +the auth server's own access-control rules say. Confirmed live: this was +the actual cause of a "Caddy proxies fine but Authelia never prompts for +login" bug, on a site block that otherwise looked completely correct. If a +service builds its own site block instead of using this helper (e.g. +`services/asterisk-digital-ocean.sh` does, deliberately, see its own +comment for why), put its auth block first there too. + +**`forward_auth` to a remote Authelia over a scheme-qualified URL needs +explicit `header_up` pins.** A bare `forward_auth authelia:9091` (Authelia on +the same Docker network, one hop) is fine relying on Caddy's default +`X-Forwarded-*` headers. But `forward_auth https://auth.example.com { ... }` +(Authelia on a *different* machine, reached over its own public domain+TLS — +see `services/asterisk-digital-ocean.sh`'s remote-Authelia prompt) is a +second Caddy hop: Caddy rewrites the outgoing request's `Host` header to +`auth.example.com` so the remote Caddy can route/SNI-match it, and without an +override `X-Forwarded-Host` picks up that rewritten value instead of the +original site's host. Confirmed live: Authelia evaluated *every* protected +domain as if the request were for `auth.example.com` itself (which typically +has `policy: bypass` in `access_control.rules` so its own login portal isn't +gated behind itself) — so every domain behind the remote instance silently +passed through with no 2FA prompt, regardless of that domain's own policy. +Fix: pin the forwarded headers to the original request explicitly instead of +trusting Caddy's default derivation: + +``` +forward_auth https://auth.example.com { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + header_up X-Forwarded-Method {method} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Uri {uri} +} +``` + +This only affects the remote-Authelia path — same-machine `authelia:9091` +snippets (`services/authelia.sh`) are a single hop and don't need it. + Sets two out-params (not `local` — read them after the call returns) so the caller can tell whether Caddy actually ended up fronting the service: diff --git a/lib/common.sh b/lib/common.sh index 80102ad..eca7cf0 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -573,6 +573,14 @@ configure_caddy_for_service() { # $SERVICE_NAME ${SERVICE_DOMAIN} { + # Auth (if any) must come before reverse_proxy — forward_auth is the + # same directive family as reverse_proxy internally, and Caddy doesn't + # reorder repeats of the same directive within a block; it runs them in + # the order they're written. With reverse_proxy first, it would handle + # and terminate every request immediately, so an auth check written + # after it would be dead code that never runs — full bypass regardless + # of what the auth server's own rules say. +${EXTRA_CONFIG} reverse_proxy ${_BLOCK_UPSTREAM} # Security headers @@ -588,7 +596,6 @@ ${SERVICE_DOMAIN} { output file /var/log/caddy/${SERVICE_DOMAIN}.log format json } -${EXTRA_CONFIG} } CADDY_BLOCK )" diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 6282c44..9a30bb2 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -584,9 +584,40 @@ ENV local _remote_authelia="" prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia if [[ -n "$_remote_authelia" ]]; then + # header_up lines are required here (unlike the local + # "authelia:9091" snippet in services/authelia.sh) because + # this upstream is reached over a second Caddy hop when + # given as a scheme-qualified URL (https://auth.example.com). + # Caddy rewrites the outgoing request's Host header to that + # upstream host so the remote Caddy can route/SNI-match it — + # and without an explicit override, X-Forwarded-Host picks up + # that rewritten value instead of the original site's host. + # Confirmed live: Authelia was evaluating every request as + # if it were for auth.example.com itself (which has + # policy: bypass in access_control.rules), so every domain + # silently passed through with no 2FA prompt regardless of + # its own policy. Pinning these to the original request's + # values fixes it regardless of hop count. + # + # X-Forwarded-Host uses a literal domain, NOT the {host} + # placeholder. Confirmed live: {host} still evaluated to + # the upstream's own hostname (auth.example.com) rather + # than the original site's — Caddy appears to rewrite the + # outgoing request's Host to the upstream target before + # header_up placeholders are resolved for a scheme- + # qualified upstream, so {host} echoes back the already- + # rewritten value instead of the original client-facing + # host. Since this site block only ever serves one domain + # (DOMAIN_NAME), hardcoding it sidesteps the ambiguity + # entirely instead of depending on Caddy's internal + # header-mutation ordering. EXTRA_BLOCK=" forward_auth ${_remote_authelia} { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + header_up X-Forwarded-Method {method} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host ${DOMAIN_NAME} + header_up X-Forwarded-Uri {uri} }" sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env log_info "Using remote Authelia at ${_remote_authelia}." @@ -628,6 +659,14 @@ ENV # Asterisk Web Admin ${DOMAIN_NAME} { + # Auth (if any) must come before reverse_proxy — forward_auth is the + # same directive family as reverse_proxy internally, and Caddy doesn't + # reorder repeats of the same directive within a block; it runs them in + # the order they're written. With reverse_proxy first, it would handle + # and terminate every request immediately, so an auth check written + # after it would be dead code that never runs — full bypass regardless + # of what the auth server's own rules say. +${EXTRA_BLOCK} reverse_proxy ${_PROXY_TARGET} header { @@ -641,7 +680,6 @@ ${DOMAIN_NAME} { output file /var/log/caddy/${DOMAIN_NAME}.log format json } -${EXTRA_BLOCK} } CADDY_BLOCK )" diff --git a/services/authelia.sh b/services/authelia.sh index 602cbf3..974668b 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -414,7 +414,20 @@ SNIPPET_EOF # ── Authelia login portal ────────────────────────────────────────────────────── auth.${AUTHELIA_DOMAIN} { - reverse_proxy authelia:9091 + # header_up pins X-Forwarded-Host to whatever the client actually sent. + # Without it, Caddy's reverse_proxy recomputes X-Forwarded-Host from its + # own incoming request (always auth.${AUTHELIA_DOMAIN} itself) and + # overwrites the value a forward_auth caller (e.g. a remote site's + # "forward_auth https://auth.${AUTHELIA_DOMAIN}" block, see + # services/asterisk-digital-ocean.sh) set for its own domain. Confirmed + # live: every forward-auth check evaluated as if it were for + # auth.${AUTHELIA_DOMAIN} itself (which has policy: bypass in + # access_control.rules so its own login portal isn't gated behind + # itself), so every domain behind it silently passed through with no + # 2FA prompt regardless of that domain's own policy. + reverse_proxy authelia:9091 { + header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} + } log { output file /var/log/caddy/auth.log }