77 lines
4.9 KiB
Markdown
77 lines
4.9 KiB
Markdown
# turnstile-caddy
|
||
|
||
Caddy v2 HTTP middleware that serves a **managed Cloudflare Turnstile** challenge for any request whose path matches a `Disallow` rule in the site’s 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` (Forgejo’s 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).
|