turnstile-caddy/README.md
Lukas Schaefer c529a6f409
All checks were successful
/ resolve-go (push) Successful in 3s
/ test (push) Successful in 5s
/ cleanup (push) Successful in 1s
Add license
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-13 18:45:05 -04:00

77 lines
4.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# turnstile-caddy
Caddy v2 HTTP middleware that serves a **managed Cloudflare Turnstile** challenge for any request whose path matches a `Disallow` rule in the sites own `/robots.txt` (`User-agent: *` group).
Well-behaved crawlers should never hit those paths; there is no good-bot allowlist. After a successful challenge, an HMAC-signed cookie skips further challenges for `pass_duration`. Optionally, set `forgejo_cookie_name` to skip challenges for authenticated Forgejo users: `Authorization: token …` or `Authorization: Bearer …` is validated with `GET /api/v1/user` (preferred), otherwise the Forgejo session cookie is validated with `GET /user/settings`. With `forgejo_remember_cookie_name`, the session pass cookie is also bound to the remember-me cookie so the bypass can survive a browser restart when the session cookie is gone.
## Build
```bash
xcaddy build \
--with git.lschaefer.xyz/lukasdotcom/turnstile-caddy
```
## Cloudflare setup
1. In the Cloudflare dashboard, create a Turnstile widget in **Managed** mode.
2. Add your site hostname to the widget.
3. Copy the site key and secret key into environment variables (below).
## Caddyfile
```caddy
git.lschaefer.xyz {
turnstile {
site_key {$TURNSTILE_SITE_KEY}
secret_key {$TURNSTILE_SECRET_KEY}
cookie_secret {$TURNSTILE_COOKIE_SECRET}
pass_duration 1h
robots_refresh 24h
upstream http://127.0.0.1:3000
forgejo_cookie_name session # omit to disable Forgejo auth bypass
forgejo_remember_cookie_name persistent # optional; bind pass cookie to remember-me too
}
reverse_proxy 127.0.0.1:3000
}
```
Generate a cookie secret once:
```bash
openssl rand -hex 32
```
| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `site_key` | yes | — | Turnstile site key |
| `secret_key` | yes | — | Turnstile secret key |
| `cookie_secret` | yes | — | HMAC key for the pass cookie |
| `cookie_name` | no | `__turnstile_pass` | Pass cookie name |
| `pass_duration` | no | `1h` | How long a solved challenge lasts |
| `robots_refresh` | no | `24h` | How often to re-fetch `/robots.txt` |
| `challenge_path` | no | `/__turnstile__/` | Prefix for verify endpoint |
| `upstream` | yes | — | Upstream base URL for `/robots.txt` and Forgejo session validation (match `reverse_proxy`) |
| `forgejo_cookie_name` | no | — | Forgejo session cookie name; enables logged-in bypass (omit to disable Forgejo auth) |
| `forgejo_remember_cookie_name` | no | — | Forgejo remember-me cookie name (`[security].COOKIE_REMEMBER_NAME`); when set, the session pass cookie is also bound to it and may validate only if no session cookie is present |
| `session_pass_cookie` | no | `__turnstile_session_pass` | HMAC pass cookie bound to the session token (only when Forgejo auth is enabled) |
## Behavior
1. On startup, the module fetches `{upstream}/robots.txt` and parses `User-agent: *` `Allow` / `Disallow` rules.
2. Until the first successful fetch, requests **fail-open** (pass through). Refresh failures keep the last good rules.
3. `/robots.txt` and `/__turnstile__/*` are never challenged.
4. Matching Disallow paths get a Turnstile interstitial (`200` HTML) with a `X-Turnstile-Challenge` header: `Anonymous` when no Forgejo session/bearer credentials were present, or `Invalid` when those credentials were present but did not authenticate. `POST /__turnstile__/verify` calls Cloudflare `siteverify`, sets the pass cookie, and redirects to the original URL. Failed verification re-shows the same page.
5. When `forgejo_cookie_name` is set, authenticated Forgejo users skip challenges. If the request has `Authorization: token …` or `Authorization: Bearer …`, validation uses `GET /api/v1/user` on `upstream` (Forgejos AuthorizationHeaderToken). Otherwise the Forgejo session cookie is checked with `GET /user/settings`; a `200` response containing the settings profile page counts as signed in and redirects are treated as unauthenticated. On success, an HMAC-signed `session_pass_cookie` is set, bound to the current credentials (authorization header, and/or session cookie, and/or remember-me cookie when `forgejo_remember_cookie_name` is set) and Forgejo username. Later requests skip re-validation when the session cookie still matches its binding. If the session cookie is absent, the remember-me binding may validate instead. If the session cookie is present but differs from the stored binding, the pass cookie is rejected and the new session is re-verified upstream. Empty session or remember-me values never count as a match. The Forgejo username is stored as `{http.auth.user.id}` (same as caddy-security), so it appears in the dedicated `user_id` field of JSON access logs when `log` is enabled. Omit `forgejo_cookie_name` to disable this bypass entirely.
CrowdSec (or other bouncers) can still ban IPs separately; this module only gates robots.txt Disallow paths.
## Tests
```bash
cd turnstile-caddy && go test ./...
```
## License
[Apache License 2.0](LICENSE).