From 861b4478a80f53be2bfeff1954625b531bfb5ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:29:26 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Fix=20Authelia=20protection=20being=20dead?= =?UTF-8?q?=20code=20=E2=80=94=20reverse=5Fproxy=20ran=20before=20the=20au?= =?UTF-8?q?th=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Caddy site block generators (the shared configure_caddy_for_service helper, and asterisk-digital-ocean.sh's own inline template) wrote reverse_proxy before the forward_auth/import authelia block. Caddy doesn't reorder repeats of the same directive within a block — forward_auth and reverse_proxy are the same directive family internally, so they run in the order written. With reverse_proxy first, it handled and terminated every request immediately; the auth check written after it never ran at all. Full bypass on every domain using either generator with Authelia protection, regardless of how correct the Authelia access_control rules themselves were — confirmed live against a config that was otherwise completely correct (default_policy: deny, explicit wildcard rule covering the affected domain). Affects every service that's ever passed `import authelia` or a forward_auth block through configure_caddy_for_service (asterisk.sh, wolf-pair.sh, and any future caller), plus asterisk-digital-ocean.sh's own site block. Moved the auth block before reverse_proxy in both generators. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi --- CLAUDE.md | 14 ++++++++++++++ lib/common.sh | 9 ++++++++- services/asterisk-digital-ocean.sh | 9 ++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f99bb85..0692e30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,20 @@ 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. + 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..480fb23 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -628,6 +628,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 +649,6 @@ ${DOMAIN_NAME} { output file /var/log/caddy/${DOMAIN_NAME}.log format json } -${EXTRA_BLOCK} } CADDY_BLOCK )" From 0764be44dac6ff3fd7cdc5d93a6b404f20940abf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:09:37 +0000 Subject: [PATCH 2/4] Fix Authelia bypass on remote-Authelia forward_auth (asterisk-digital-ocean) The remote-Authelia forward_auth block dialed a scheme-qualified upstream (https://auth.example.com), which is a second Caddy hop. Caddy rewrites the outgoing Host header to the upstream host for routing, and without an explicit override X-Forwarded-Host picked up that rewritten value instead of the original site's host. Authelia was evaluating every protected domain as auth.example.com itself (bypass policy), so 2FA never triggered for any domain behind the remote instance. Pin the forwarded headers to the original request explicitly to fix it. --- CLAUDE.md | 31 ++++++++++++++++++++++++++++++ services/asterisk-digital-ocean.sh | 18 +++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0692e30..5869174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,37 @@ 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/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 480fb23..235c85d 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -584,9 +584,27 @@ 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. 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 {host} + 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}." From 2ebbe5e5295afd348bcdc9e02eec83124ac6b77e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 21:06:06 +0000 Subject: [PATCH 3/4] Hardcode X-Forwarded-Host in remote-Authelia forward_auth, not {host} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used the {host} Caddy placeholder for X-Forwarded-Host, but confirmed live it still evaluated to the upstream Authelia's own hostname rather than the original site's — Caddy appears to rewrite the outgoing request's Host to the upstream target before header_up placeholders resolve for a scheme-qualified remote upstream, so {host} echoed back the already-rewritten value. Since this site block only ever serves one domain, hardcode it instead of depending on placeholder timing. --- services/asterisk-digital-ocean.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 235c85d..9a30bb2 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -598,12 +598,25 @@ ENV # 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 {host} + 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 From 9aacb17a4db504c53f6ba97eb17497e731b899ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 22:31:07 +0000 Subject: [PATCH 4/4] Pin X-Forwarded-Host on the Authelia portal's own Caddy block The generated auth. block's bare "reverse_proxy authelia:9091" let Caddy recompute X-Forwarded-Host from its own incoming request (always auth. itself) on every hop through it, overwriting whatever a forward_auth caller elsewhere had already set for its own domain. Confirmed live: a remote site's forward_auth check always evaluated as if it were for the Authelia portal itself (bypass policy), so 2FA silently never triggered for any domain going through it. --- services/authelia.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 }