turnstile-caddy/turnstile.go
Lukas Schaefer d38dff43a1
All checks were successful
/ resolve-go (push) Successful in 3s
/ test (push) Successful in 6s
/ cleanup (push) Successful in 1s
Implement remember me support to survive browser restarts
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-12 23:24:56 -04:00

444 lines
11 KiB
Go

package turnstile
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"go.uber.org/zap"
)
func init() {
caddy.RegisterModule(Middleware{})
httpcaddyfile.RegisterHandlerDirective("turnstile", parseCaddyfile)
httpcaddyfile.RegisterDirectiveOrder("turnstile", httpcaddyfile.Before, "reverse_proxy")
}
// Middleware challenges requests whose path matches Disallow rules from the
// site's robots.txt (User-agent: *) using a managed Cloudflare Turnstile page.
type Middleware struct {
SiteKey string `json:"site_key,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
CookieSecret string `json:"cookie_secret,omitempty"`
CookieName string `json:"cookie_name,omitempty"`
PassDuration caddy.Duration `json:"pass_duration,omitempty"`
RobotsRefresh caddy.Duration `json:"robots_refresh,omitempty"`
ChallengePath string `json:"challenge_path,omitempty"`
ForgejoCookieName string `json:"forgejo_cookie_name,omitempty"`
ForgejoRememberCookieName string `json:"forgejo_remember_cookie_name,omitempty"`
Upstream string `json:"upstream,omitempty"`
SessionPassCookie string `json:"session_pass_cookie,omitempty"`
logger *zap.Logger
robots *robotsStore
cookies *cookieSigner
sessionPasses *sessionPassSigner
tmpl *template.Template
verify string
httpClient *http.Client
}
// CaddyModule returns the Caddy module information.
func (Middleware) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.turnstile",
New: func() caddy.Module { return new(Middleware) },
}
}
// Provision sets up the module.
func (m *Middleware) Provision(ctx caddy.Context) error {
m.logger = ctx.Logger()
if m.SiteKey == "" {
return fmt.Errorf("turnstile: site_key is required")
}
if m.SecretKey == "" {
return fmt.Errorf("turnstile: secret_key is required")
}
if m.CookieSecret == "" {
return fmt.Errorf("turnstile: cookie_secret is required")
}
if m.PassDuration == 0 {
m.PassDuration = caddy.Duration(time.Hour)
}
if m.RobotsRefresh == 0 {
m.RobotsRefresh = caddy.Duration(24 * time.Hour)
}
if m.ChallengePath == "" {
m.ChallengePath = "/__turnstile__/"
}
if !strings.HasPrefix(m.ChallengePath, "/") {
m.ChallengePath = "/" + m.ChallengePath
}
if !strings.HasSuffix(m.ChallengePath, "/") {
m.ChallengePath += "/"
}
m.verify = m.ChallengePath + "verify"
signer, err := newCookieSigner(m.CookieSecret, m.CookieName, time.Duration(m.PassDuration))
if err != nil {
return err
}
m.cookies = signer
if m.Upstream == "" {
return fmt.Errorf("turnstile: upstream is required")
}
upstream, err := normalizeUpstream(m.Upstream)
if err != nil {
return err
}
m.Upstream = upstream
if m.ForgejoCookieName != "" {
sessionSigner, err := newSessionPassSigner(m.CookieSecret, m.SessionPassCookie, time.Duration(m.PassDuration))
if err != nil {
return err
}
m.sessionPasses = sessionSigner
}
tmpl, err := template.New("challenge").Parse(challengeHTML)
if err != nil {
return fmt.Errorf("turnstile: parse challenge template: %w", err)
}
m.tmpl = tmpl
m.robots = newRobotsStore(m.Upstream, time.Duration(m.RobotsRefresh), func(format string, args ...any) {
m.logger.Info(fmt.Sprintf(format, args...))
})
m.httpClient = &http.Client{Timeout: 10 * time.Second}
return nil
}
// Validate validates the module configuration.
func (m *Middleware) Validate() error {
if m.SiteKey == "" || m.SecretKey == "" || m.CookieSecret == "" {
return fmt.Errorf("turnstile: site_key, secret_key, and cookie_secret are required")
}
if m.Upstream == "" {
return fmt.Errorf("turnstile: upstream is required")
}
return nil
}
// Cleanup stops background robots refresh.
func (m *Middleware) Cleanup() error {
if m.robots != nil {
m.robots.close()
}
return nil
}
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (m Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
path := r.URL.Path
if path == m.verify {
if r.Method == http.MethodPost {
return m.handleVerify(w, r)
}
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
if strings.HasPrefix(path, m.ChallengePath) {
http.NotFound(w, r)
return nil
}
sessionAuthed := m.ensureSessionUserID(w, r)
if path == "/robots.txt" {
return next.ServeHTTP(w, r)
}
if m.cookies.valid(r) {
return next.ServeHTTP(w, r)
}
if !m.robots.ensureLoaded() {
// Fail-open until robots.txt is available.
return next.ServeHTTP(w, r)
}
if !m.robots.isDisallowed(path, r.URL.RawQuery) {
return next.ServeHTTP(w, r)
}
if sessionAuthed {
return next.ServeHTTP(w, r)
}
return m.serveChallenge(w, r, "")
}
const (
challengeHeader = "X-Turnstile-Challenge"
challengeAnonymous = "Anonymous"
challengeInvalid = "Invalid"
)
// challengeReason reports why the request is being challenged.
// Invalid means the request presented Forgejo session or bearer/token credentials
// that did not authenticate; Anonymous means no such credentials were present.
func (m *Middleware) challengeReason(r *http.Request) string {
if m.ForgejoCookieName == "" || m.sessionPasses == nil {
return challengeAnonymous
}
if _, ok := forgejoAuthorizationHeaderToken(r); ok {
return challengeInvalid
}
if m.sessionToken(r) != "" || m.rememberToken(r) != "" {
return challengeInvalid
}
return challengeAnonymous
}
func (m *Middleware) serveChallenge(w http.ResponseWriter, r *http.Request, errMsg string) error {
redirect := r.URL.RequestURI()
if r.Method == http.MethodPost {
if v := r.FormValue("redirect"); v != "" {
redirect = v
}
}
if !isSafeRedirect(redirect) {
redirect = "/"
}
var buf bytes.Buffer
data := struct {
SiteKey string
VerifyPath string
Redirect string
Error string
}{
SiteKey: m.SiteKey,
VerifyPath: m.verify,
Redirect: redirect,
Error: errMsg,
}
if err := m.tmpl.Execute(&buf, data); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set(challengeHeader, m.challengeReason(r))
w.WriteHeader(http.StatusOK)
_, err := w.Write(buf.Bytes())
return err
}
func (m *Middleware) handleVerify(w http.ResponseWriter, r *http.Request) error {
if err := r.ParseForm(); err != nil {
return m.serveChallenge(w, r, "Invalid form submission.")
}
token := r.FormValue("cf-turnstile-response")
redirect := r.FormValue("redirect")
if !isSafeRedirect(redirect) {
redirect = "/"
}
if token == "" {
r.URL.RawQuery = ""
r.URL.Path = redirect
return m.serveChallenge(w, r, "Missing challenge token.")
}
ok, err := m.verifyTurnstile(r, token)
if err != nil {
m.logger.Warn("turnstile siteverify error", zap.Error(err))
r.Method = http.MethodGet
r.URL.Path = redirectPath(redirect)
r.URL.RawQuery = redirectQuery(redirect)
return m.serveChallenge(w, r, "Verification temporarily unavailable.")
}
if !ok {
r.Method = http.MethodGet
r.URL.Path = redirectPath(redirect)
r.URL.RawQuery = redirectQuery(redirect)
return m.serveChallenge(w, r, "Verification failed. Please try again.")
}
if err := m.cookies.set(w, r); err != nil {
return err
}
http.Redirect(w, r, redirect, http.StatusFound)
return nil
}
func (m *Middleware) verifyTurnstile(r *http.Request, token string) (bool, error) {
form := url.Values{}
form.Set("secret", m.SecretKey)
form.Set("response", token)
if ip := clientIP(r); ip != "" {
form.Set("remoteip", ip)
}
resp, err := m.httpClient.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", form)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return false, err
}
var result struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(body, &result); err != nil {
return false, err
}
return result.Success, nil
}
func requestScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return strings.ToLower(strings.Split(proto, ",")[0])
}
if r.TLS != nil {
return "https"
}
return "http"
}
func isSafeRedirect(u string) bool {
if u == "" || !strings.HasPrefix(u, "/") || strings.HasPrefix(u, "//") {
return false
}
if strings.ContainsAny(u, "\r\n") {
return false
}
return true
}
func redirectPath(u string) string {
if i := strings.IndexByte(u, '?'); i >= 0 {
return u[:i]
}
return u
}
func redirectQuery(u string) string {
if i := strings.IndexByte(u, '?'); i >= 0 {
return u[i+1:]
}
return ""
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
//
// turnstile {
// site_key <key>
// secret_key <key>
// cookie_secret <secret>
// cookie_name <name>
// pass_duration <duration>
// robots_refresh <duration>
// challenge_path <path>
// upstream <url>
// forgejo_cookie_name <name>
// forgejo_remember_cookie_name <name>
// session_pass_cookie <name>
// }
func (m *Middleware) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
d.Next() // consume directive name
for d.NextBlock(0) {
switch d.Val() {
case "site_key":
if !d.NextArg() {
return d.ArgErr()
}
m.SiteKey = d.Val()
case "secret_key":
if !d.NextArg() {
return d.ArgErr()
}
m.SecretKey = d.Val()
case "cookie_secret":
if !d.NextArg() {
return d.ArgErr()
}
m.CookieSecret = d.Val()
case "cookie_name":
if !d.NextArg() {
return d.ArgErr()
}
m.CookieName = d.Val()
case "pass_duration":
if !d.NextArg() {
return d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return d.Errf("invalid pass_duration: %v", err)
}
m.PassDuration = caddy.Duration(dur)
case "robots_refresh":
if !d.NextArg() {
return d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return d.Errf("invalid robots_refresh: %v", err)
}
m.RobotsRefresh = caddy.Duration(dur)
case "challenge_path":
if !d.NextArg() {
return d.ArgErr()
}
m.ChallengePath = d.Val()
case "upstream":
if !d.NextArg() {
return d.ArgErr()
}
m.Upstream = d.Val()
case "forgejo_cookie_name":
if !d.NextArg() {
return d.ArgErr()
}
m.ForgejoCookieName = d.Val()
case "forgejo_remember_cookie_name":
if !d.NextArg() {
return d.ArgErr()
}
m.ForgejoRememberCookieName = d.Val()
case "session_pass_cookie":
if !d.NextArg() {
return d.ArgErr()
}
m.SessionPassCookie = d.Val()
default:
return d.Errf("unknown option: %s", d.Val())
}
}
return nil
}
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var m Middleware
err := m.UnmarshalCaddyfile(h.Dispenser)
return m, err
}
// Interface guards
var (
_ caddy.Provisioner = (*Middleware)(nil)
_ caddy.Validator = (*Middleware)(nil)
_ caddy.CleanerUpper = (*Middleware)(nil)
_ caddyhttp.MiddlewareHandler = (*Middleware)(nil)
_ caddyfile.Unmarshaler = (*Middleware)(nil)
)