# 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).