internal/server/captcha.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/X4Applegate/caddyui/internal/models"
)
// Captcha providers. Stored in the settings table under the key
// settingCaptchaProvider. "off" means the captcha is disabled outright —
// the widget won't render and POST handlers skip verification. The value
// is case-insensitive on read; writes go through normalizeCaptchaProvider.
const (
captchaProviderOff = "off"
captchaProviderTurnstile = "turnstile"
captchaProviderRecaptcha = "recaptcha"
)
// Settings-table keys for captcha configuration. Turnstile's two keys
// predate v2.5.0 (shipped in v2.4.x) — we keep the names for back-compat
// so an upgrade doesn't wipe them. The new keys are for reCAPTCHA.
const (
settingCaptchaProvider = "captcha_provider"
// Turnstile keys live under their original names from v2.4.x.
// (settingTurnstileSiteKey / settingTurnstileSecretKey are declared
// in server.go — keeping one canonical declaration there.)
// Google reCAPTCHA v3 keys. Site key goes in the page (public);
// secret key never leaves the server.
settingRecaptchaSiteKey = "recaptcha_site_key"
settingRecaptchaSecretKey = "recaptcha_secret_key"
// reCAPTCHA v3 returns a score between 0.0 (bot) and 1.0 (human).
// captchaDefaultMinScore is what we reject-below when the admin hasn't
// tuned the threshold. Google recommends 0.5 as a starting point.
settingRecaptchaMinScore = "recaptcha_min_score"
captchaDefaultMinScore = 0.5
// Env-var kill switch. Set CADDYUI_CAPTCHA_DISABLE=1 to bypass the
// captcha entirely (no verification, no widget render) without
// touching the DB. Exists specifically for "Cloudflare outage +
// I'm locked out of my own admin" scenarios — the user can restart
// the container with this flag, log in, then unset it.
envCaptchaDisable = "CADDYUI_CAPTCHA_DISABLE"
)
// captchaConfig is the snapshot of captcha settings resolved at request
// time. Built by loadCaptchaConfig and passed to handlers + templates as
// a single struct so nothing downstream has to poke at models.GetSetting.
type captchaConfig struct {
// Provider is one of captchaProviderOff / captchaProviderTurnstile /
// captchaProviderRecaptcha. If env CADDYUI_CAPTCHA_DISABLE=1 this is
// forced to "off" even when the DB has a provider configured.
Provider string
// Site key is rendered into the page. Empty when Provider is "off"
// or when the admin hasn't filled in the key for the active provider
// yet (in which case Enabled() returns false and we skip the widget).
SiteKey string
// SecretKey is used server-side to call the provider's verify endpoint.
// Never sent to the template.
SecretKey string
// MinScore is reCAPTCHA v3's rejection threshold. Ignored for
// Turnstile (which returns a boolean). Defaults to captchaDefaultMinScore.
MinScore float64
}
// Enabled returns true when both the provider and the keys are configured.
// Handlers use this to decide whether to verify; templates use it to
// decide whether to render the widget. A half-configured setup (provider
// set but keys blank) is treated as disabled rather than as a wall users
// can't get past.
func (c captchaConfig) Enabled() bool {
if c.Provider == captchaProviderOff || c.Provider == "" {
return false
}
return c.SiteKey != "" && c.SecretKey != ""
}
// loadCaptchaConfig reads the active captcha configuration from the DB,
// applies the env kill-switch, and normalises the provider name. Safe
// to call on every request — the settings table is small and backed
// by SQLite's page cache.
func loadCaptchaConfig(db *sql.DB) captchaConfig {
// Env kill-switch short-circuits before any DB work. Any truthy value
// ("1", "true", "yes", case-insensitive) disables the captcha.
if captchaDisabledByEnv() {
return captchaConfig{Provider: captchaProviderOff}
}
provider := normalizeCaptchaProvider(mustGetSetting(db, settingCaptchaProvider))
cfg := captchaConfig{Provider: provider, MinScore: captchaDefaultMinScore}
switch provider {
case captchaProviderTurnstile:
cfg.SiteKey = mustGetSetting(db, settingTurnstileSiteKey)
cfg.SecretKey = mustGetSetting(db, settingTurnstileSecretKey)
case captchaProviderRecaptcha:
cfg.SiteKey = mustGetSetting(db, settingRecaptchaSiteKey)
cfg.SecretKey = mustGetSetting(db, settingRecaptchaSecretKey)
if raw := strings.TrimSpace(mustGetSetting(db, settingRecaptchaMinScore)); raw != "" {
if f, err := strconv.ParseFloat(raw, 64); err == nil && f >= 0 && f <= 1 {
cfg.MinScore = f
}
}
}
return cfg
}
// mustGetSetting is a thin wrapper over models.GetSetting that discards
// the error and returns "" on miss. Every caller in this file already
// wraps empties as "not configured", so surfacing the error would only
// add noise.
func mustGetSetting(db *sql.DB, key string) string {
v, _ := models.GetSetting(db, key)
return v
}
// normalizeCaptchaProvider coerces free-form input (from the settings
// form or a hand-edited DB) into one of the three canonical values.
// Unknown values collapse to "off" rather than being trusted — keeps
// /login from rendering a broken widget that would lock admins out.
func normalizeCaptchaProvider(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case captchaProviderTurnstile:
return captchaProviderTurnstile
case captchaProviderRecaptcha:
return captchaProviderRecaptcha
}
return captchaProviderOff
}
// captchaDisabledByEnv reports whether the CADDYUI_CAPTCHA_DISABLE env var
// is set to a truthy value. Accepts 1/true/yes (case-insensitive) so
// docker-compose.yml can use any common form without surprising behaviour.
func captchaDisabledByEnv() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(envCaptchaDisable)))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
// verifyCaptcha validates the captcha response that came back with the
// request. Dispatches to Turnstile's or reCAPTCHA's siteverify endpoint
// based on cfg.Provider. Returns (true, nil) on success, (false, err)
// on network/decode failure, (false, nil) when the provider said no.
//
// When cfg.Enabled() is false, this is a no-op that returns (true, nil)
// — handlers can call it unconditionally without first checking enabled.
func verifyCaptcha(cfg captchaConfig, r *http.Request) (bool, error) {
if !cfg.Enabled() {
return true, nil
}
// Remote IP goes to the provider as a hint. Prefer X-Forwarded-For's
// first hop (we're behind Caddy, which always sets this). Fall back
// to RemoteAddr when the header is absent — e.g. during local testing.
remoteIP := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
remoteIP = strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
}
switch cfg.Provider {
case captchaProviderTurnstile:
token := r.FormValue("cf-turnstile-response")
return verifyTurnstile(cfg.SecretKey, token, remoteIP)
case captchaProviderRecaptcha:
token := r.FormValue("g-recaptcha-response")
// When the token is empty, dump the form keys + user-agent so we
// can tell whether the hidden input wasn't submitted, was submitted
// empty, or the form body itself is malformed. The old log line
// ("api.js may not have loaded") was correct but didn't give the
// admin enough info to act — now we can see if the browser actually
// posted the field and the token just never got populated.
if token == "" {
keys := make([]string, 0, len(r.PostForm))
for k := range r.PostForm {
if k == "password" || k == "new_password" || k == "confirm_password" || k == "totp" {
continue // never log passwords or TOTP codes
}
keys = append(keys, k)
}
log.Printf("recaptcha: empty token — posted_keys=%v ua=%q remote=%s", keys, r.Header.Get("User-Agent"), remoteIP)
}
return verifyRecaptcha(cfg.SecretKey, token, remoteIP, cfg.MinScore)
}
return true, nil
}
// verifyRecaptcha calls Google's reCAPTCHA v3 siteverify endpoint.
// Returns true when the challenge is valid AND the returned score is
// at or above minScore. v3 is invisible — no user interaction required —
// which is why we enforce a score threshold; v2 (checkbox) is not
// supported here to keep the UX consistent with Turnstile's managed mode.
//
// See https://developers.google.com/recaptcha/docs/verify for the
// response schema. The "action" field can be matched against what the
// client sent in grecaptcha.execute; we don't enforce it in v2.5.0 since
// we only call v3 from three forms and any human-valid token is fine.
func verifyRecaptcha(secretKey, token, remoteIP string, minScore float64) (bool, error) {
if token == "" {
// Empty token always fails. Don't bother hitting Google.
log.Printf("recaptcha: reject — no token in form (api.js may not have loaded)")
return false, nil
}
if strings.TrimSpace(secretKey) == "" {
// A misconfigured admin (provider set to reCAPTCHA but the secret
// blank) would have Enabled() return false and never reach here —
// but paranoid guard so a partially-wiped settings row doesn't pass
// a literal "" secret to Google and trigger missing-input-secret.
log.Printf("recaptcha: reject — secret key is blank in DB (check Settings → CAPTCHA → reCAPTCHA)")
return false, nil
}
// Short HTTP timeout so a Google outage doesn't wedge /login for
// minutes. If Google is slow we'd rather surface a retry than block
// a legit user behind a 30-second hang.
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.PostForm("https://www.google.com/recaptcha/api/siteverify",
url.Values{
"secret": {secretKey},
"response": {token},
"remoteip": {remoteIP},
})
if err != nil {
log.Printf("recaptcha: network error talking to Google: %v", err)
return false, fmt.Errorf("recaptcha verify: %w", err)
}
defer resp.Body.Close()
// v3 response schema: {"success":bool, "score":float, "action":str,
// "challenge_ts":ts, "hostname":str, "error-codes":[...]}.
// We decode error-codes so the log line below gives the admin a
// concrete reason instead of a generic "Security check failed" —
// common values are invalid-input-secret (wrong key / v2 key pasted
// into v3 slot), timeout-or-duplicate (token reused or >2min old),
// and hostname-mismatch (site not registered at the current domain).
var result struct {
Success bool `json:"success"`
Score float64 `json:"score"`
Action string `json:"action"`
Hostname string `json:"hostname"`
ErrorCodes []string `json:"error-codes"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Printf("recaptcha: decode Google response: %v", err)
return false, fmt.Errorf("recaptcha decode: %w", err)
}
if !result.Success {
// Google said the token itself is bad — most common cause is a
// reversed or wrong secret key, or a stale/reused token.
log.Printf("recaptcha: Google rejected token — error-codes=%v hostname=%q action=%q (check Settings → CAPTCHA if this is a keys mismatch)",
result.ErrorCodes, result.Hostname, result.Action)
return false, nil
}
if result.Score < minScore {
// Token was valid but the bot-score was below the admin's threshold.
// Default is 0.5; if the admin set 0.9 they'll see legitimate users
// bounce here — Google recommends keeping it around 0.5.
log.Printf("recaptcha: score %.2f below threshold %.2f — hostname=%q action=%q (lower Settings → CAPTCHA → Min score if this blocks real users)",
result.Score, minScore, result.Hostname, result.Action)
return false, nil
}
log.Printf("recaptcha: ok — score=%.2f threshold=%.2f action=%q", result.Score, minScore, result.Action)
return true, nil
}
// captchaTemplateData returns the map the form templates merge into
// their render context to light up the widget. Keeps the field names
// consistent across login.html, totp_verify.html, and user_form.html
// so the shared widget partial can pick them up without per-page
// naming drift.
//
// Keys:
//
// CaptchaProvider — "off" | "turnstile" | "recaptcha". Templates use
// this in {{if eq .CaptchaProvider "turnstile"}}
// CaptchaSiteKey — public key to render into the widget's attribute
// CaptchaEnabled — convenience bool for {{if .CaptchaEnabled}} checks
func captchaTemplateData(cfg captchaConfig) map[string]any {
return map[string]any{
"CaptchaProvider": cfg.Provider,
"CaptchaSiteKey": cfg.SiteKey,
"CaptchaEnabled": cfg.Enabled(),
}
}