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

4.9 KiB
Raw Permalink Blame History

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

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


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:

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

cd turnstile-caddy && go test ./...

License

Apache License 2.0.