// 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(), } }