444 lines
11 KiB
Go
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)
|
|
)
|