Verify Cloudflare Access's signed token before trusting the proxy
The proxy sign-in believed Cf-Access-Authenticated-User-Email from any address in trusted_proxies. On Tower that address is the Docker gateway, so any container there could name itself anyone (docs/sso.md said as much, and CLAUDE.md listed it as a known gap). With [web] access_team and access_aud set, a proxied request must also carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys (RS256 only, this application's audience, the team's issuer, not expired), and the name comes from its email claim. The keys are fetched at start and again when a token names an unseen key, at most once a minute, so made-up key ids cannot make every request a request to Cloudflare. While the keys cannot be had, proxied sign-in is refused; password and token sign-in are unaffected. Both settings empty, nothing changes. jsonwebtoken does the checking, on the aws-lc-rs backend already in the tree through rustls. Tests sign with throwaway keys in tests/data: a valid token, another app's audience, expired, a forged signature, HS256, alg none, the refetch limit, and keys that cannot be fetched. Checked live on a scratch daemon: the header alone and a forged token got 401, the admin token still signed in. vouched_name takes the peer and headers rather than the request: a &Request held across the new await made the auth middleware's future unsendable, as a body is not Sync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
||||
token = "" # generated and saved on first run
|
||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1"]
|
||||
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||
access_aud = "" # the Access application's AUD tag
|
||||
auto_create_users = true
|
||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
@@ -94,6 +96,9 @@ session_days = 30
|
||||
disables that path. See [sso.md](sso.md).
|
||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||
for it. Name the proxy, never a subnet.
|
||||
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||
|
||||
30
docs/sso.md
30
docs/sso.md
@@ -172,9 +172,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
||||
sides. Never list a LAN address or range: anyone there could then send
|
||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||
|
||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
||||
boundary, and is the upgrade if that ever matters.
|
||||
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||
a container on Tower cannot make one.
|
||||
|
||||
### Verifying Cloudflare's token
|
||||
|
||||
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||
request, and takes the name from its `email` claim.
|
||||
|
||||
```toml
|
||||
[web]
|
||||
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||
```
|
||||
|
||||
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||
the keys cannot be fetched; password and token sign-in still work then.
|
||||
|
||||
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||
place.
|
||||
|
||||
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||
|
||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||
in with them until they are given a password (`ipx user passwd <name>`).
|
||||
|
||||
Reference in New Issue
Block a user