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 // secret_key // cookie_secret // cookie_name // pass_duration // robots_refresh // challenge_path // upstream // forgejo_cookie_name // forgejo_remember_cookie_name // session_pass_cookie // } 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) )