Documentation
¶
Overview ¶
Package captcha is filex's bot check on the forms anybody can post without signing in: the sign-in, a demo's sign-in, the PIN of a protected link and the upload into a file request (docs/BOT-PROTECTION.md).
The provider is Cloudflare Turnstile (or Google reCAPTCHA v2). When the provider cannot be reached - a network failure, a timeout, a 5xx, an answer that is not JSON - or the configuration cannot work (no site key, no secret, a secret the provider refuses), the form is NOT left open and NOT shut: the browser does a short proof of work instead (local.go), and the administrators are told. A provider that answered and said no (4xx, 429 included, or success:false) is a refusal (422), never an outage: otherwise anybody could make the provider rate-limit a form and get the cheap check.
⚠⚠ The settings names, the answers (422 / 409 / 503), the outage flag and the local fallback are a contract shared with the other products that run the same check; filex implements it, it does not vary it.
Index ¶
- Constants
- Variables
- func EncodeSolution(c Challenge, number int64) string
- func ExpiresOf(salt string) (int64, bool)
- func FrameOrigin(cfg Config) string
- func HashFor(salt string, number int64) string
- func ScriptFor(provider string) string
- func SignatureFor(key []byte, hash string) string
- func Solve(c Challenge) int64
- func TokenShapeOK(provider, token string) bool
- type Alarms
- type Challenge
- type Config
- type Consume
- type Fields
- type Invalid
- type Local
- type LocalResult
- type Marks
- type MemoryMarks
- func (m *MemoryMarks) AddCaptchaMark(_ context.Context, key string, expires, now int64) (bool, error)
- func (m *MemoryMarks) CaptchaMarkUntil(_ context.Context, key string) (int64, bool, error)
- func (m *MemoryMarks) Len() int
- func (m *MemoryMarks) PruneCaptchaMarks(_ context.Context, now int64) (int64, error)
- func (m *MemoryMarks) PutCaptchaMark(_ context.Context, key string, expires int64) error
- type Patch
- type Probe
- type PublicConfig
- type Result
- type SettingStore
- type Settings
- type Status
- type Stored
- type Verifier
- func (v *Verifier) ChallengeAnswer(ctx context.Context) (int, map[string]any)
- func (v *Verifier) Check(ctx context.Context, form string, f Fields, ip string) Result
- func (v *Verifier) Config(ctx context.Context) Config
- func (v *Verifier) FallbackActive(ctx context.Context) bool
- func (v *Verifier) FallbackTotal() int64
- func (v *Verifier) IssuePass(scope string) (string, time.Time)
- func (v *Verifier) ProbeWith(ctx context.Context, cfg Config) Probe
- func (v *Verifier) Public(ctx context.Context) PublicConfig
- func (v *Verifier) SpendPass(ctx context.Context, pass, scope string) bool
Constants ¶
const ( // MaxPayloadBytes is the longest `captcha_local` value read. MaxPayloadBytes = 2048 // MaxSaltLength is the longest salt a challenge carries. MaxSaltLength = 256 // DefaultLocalTTL is how long a challenge may be solved in. DefaultLocalTTL = 600 * time.Second // DefaultMaxNumber is the top of the range the browser scans: about one to // three seconds on a desktop, under eight on a phone. DefaultMaxNumber = 200000 )
Limits of the local fallback.
const ( ReasonBadPayload = "bad_payload" ReasonBadAlgorithm = "bad_algorithm" ReasonBadSalt = "bad_salt" ReasonExpired = "expired" ReasonBadNumber = "bad_number" ReasonBadSignature = "bad_signature" ReasonBadHash = "bad_hash" ReasonReplayed = "replayed" )
The reasons a solution is refused, in the order the steps run.
const ( ProviderNone = "none" ProviderTurnstile = "turnstile" ProviderRecaptcha = "recaptcha" )
The providers.
const ( // FallbackLocal: the browser does the local proof of work. FallbackLocal = "local" // FallbackOff: the form answers 503 instead. For tests only. FallbackOff = "off" )
What happens when the provider cannot be reached.
const ( // FormLogin is the password sign-in, the recovery sign-in of an SSO // installation included. FormLogin = "login" // FormDemoLogin is a demo's one-click sign-in (FILEX_DEMO). FormDemoLogin = "demo_login" // app's page, a file request. FormSharePassword = "share_password" // FormFileRequest is the upload into a file request. FormFileRequest = "file_request" )
The forms filex protects, each on or off in `captcha.forms`.
const ( KeyProvider = "captcha.provider" KeySiteKey = "captcha.site_key" // KeySecret is sealed (internal/secretbox, bound to this row). Its last // segment is a secret's name, so the generic settings list masks it. KeySecret = "captcha.secret" KeyForms = "captcha.forms" KeyFallback = "captcha.fallback" KeyTimeout = "captcha.verify_timeout_ms" )
The settings rows: the shared contract's names.
const ( EnvProvider = "CAPTCHA_PROVIDER" EnvSiteKey = "CAPTCHA_SITE_KEY" EnvSecret = "CAPTCHA_SECRET" EnvForms = "CAPTCHA_FORMS" EnvFallback = "CAPTCHA_FALLBACK" EnvTimeout = "CAPTCHA_VERIFY_TIMEOUT_MS" )
The environment, read when the panel leaves a field empty.
⚠⚠ Not a seed. filex's other database settings (internal/dbsetting) read their variable once, at the first boot, into the row; these are read LIVE whenever the row is empty - the order the shared contract sets for every product: the panel, then the environment, then off.
const ( DefaultVerifyTimeout = 4 * time.Second MinVerifyTimeoutMS = 1000 MaxVerifyTimeoutMS = 15000 )
The verify timeout's bounds and default.
const ( MisSiteKeyMissing = "site_key_missing" MisSecretMissing = "secret_missing" MisSecretUnreadable = "secret_unreadable" // The provider's own refusal of the secret (siteverify error codes). MisInvalidSecret = "invalid-input-secret" MisMissingSecret = "missing-input-secret" )
The reasons a configuration cannot be used (Config.Misconfiguration).
const ( SourceDB = "db" SourceEnv = "env" SourceDefault = "default" )
The layer a value came from (Config.Sources).
const ( TurnstileSiteverify = "https://challenges.cloudflare.com/turnstile/v0/siteverify" RecaptchaSiteverify = "https://www.google.com/recaptcha/api/siteverify" TurnstileScript = "https://challenges.cloudflare.com/turnstile/v0/api.js" RecaptchaScript = "https://www.google.com/recaptcha/api.js" // The origins the widget draws its frame from (the pages' frame-src). TurnstileFrame = "https://challenges.cloudflare.com" RecaptchaFrame = "https://www.google.com" )
The providers' addresses.
const ( EnvTurnstileSiteverify = "CAPTCHA_TURNSTILE_SITEVERIFY" EnvRecaptchaSiteverify = "CAPTCHA_RECAPTCHA_SITEVERIFY" )
The test-only addresses of siteverify (an e2e run's "the provider is unreachable"). Empty in production.
const ( // TokenField is the provider token's canonical name; the provider's own // names (`cf-turnstile-response`, `g-recaptcha-response`) and // `turnstile_token` are read as well. TokenField = "captcha_token" // LocalField carries a local solution (base64 JSON). LocalField = "captcha_local" )
The form fields.
const ( // FallbackFlagTTL is how long the outage flag lives after the last // failure to reach the provider. FallbackFlagTTL = 120 * time.Second // TokenTTL is how long an accepted provider token is remembered. TokenTTL = 5 * time.Minute // PassTTL is how long an upload pass may wait for its upload. PassTTL = 10 * time.Minute // AlarmWindow and AlarmThreshold: more than this many fallbacks in the // window tell the administrators. AlarmWindow = 10 * time.Minute AlarmThreshold = 1 // MisconfiguredAlarmEvery is how often one misconfiguration is told. MisconfiguredAlarmEvery = time.Hour )
The lifetimes the contract sets.
const ( ReasonMissingToken = "missing_token" ReasonInvalidToken = "invalid_token" ReasonTokenExpired = "token_expired" ReasonBadToken = "bad_token" ReasonProviderRejected = "provider_rejected" ReasonFallbackInactive = "fallback_inactive" ReasonFallbackOff = "fallback_off" )
The reasons of an invalid result, besides the local fallback's own.
const ( TurnstileTokenMax = 2048 RecaptchaTokenMax = 4096 )
The tokens' shape, measured 2026-11-03 on real tokens: Turnstile's test key gave 21 characters ([A-Z.]), a live Turnstile widget 730 ([A-Za-z0-9._-]), reCAPTCHA v2 1678 and 2297 ([A-Za-z0-9_-]). Turnstile documents at most 2048; reCAPTCHA documents none, so its bound is 4096. ⚠ Go's `$` is the end of the text (no multi-line flag): a trailing line break does not slip through.
const Algorithm = "SHA-256"
Algorithm is the only one there is.
const ( // FlagFallback is the outage flag: while it lives, a local solution is // accepted. FlagFallback = "captcha:fallback:active" )
The mark keys.
const PassHeader = "X-Captcha-Pass"
PassHeader carries the pass on the upload.
Variables ¶
var ErrNoSecretKey = errors.New("captcha: no FILEX_SECRET_KEY to seal the secret with")
ErrNoSecretKey: a secret was given and there is no FILEX_SECRET_KEY to seal it with.
var Forms = []string{FormLogin, FormDemoLogin, FormSharePassword, FormFileRequest}
Forms is every form, in the order the settings page lists them. It is also what is protected when nothing says otherwise.
var Providers = []string{ProviderNone, ProviderTurnstile, ProviderRecaptcha}
Providers is every value `captcha.provider` takes.
Functions ¶
func EncodeSolution ¶
EncodeSolution packs a solution the way the browser does.
func ExpiresOf ¶
ExpiresOf reads `expires` (unix seconds) from the salt's query; any other parameter is ignored, the last `expires` wins.
func FrameOrigin ¶
FrameOrigin is the origin the provider's widget frames, "" when the check is off: the pages' frame-src names it.
func SignatureFor ¶
SignatureFor is HMAC-SHA256(key, lowercase hash), lowercase hex.
func Solve ¶
Solve finds the number by scanning, as the browser's worker does (tests, the CLI). -1 when there is none in range.
func TokenShapeOK ¶
TokenShapeOK reports whether token is the provider's shape: its length bound and the narrow character set.
Types ¶
type Alarms ¶
type Alarms struct {
// Counted is told every time the local fallback is handed out (the
// metric); cause is "unreachable" or "misconfigured".
Counted func(cause string)
// Fallback is told once per window when the window's fallbacks pass the
// threshold.
Fallback func(count int, window time.Duration, cause, form string)
// Misconfigured is told once an hour per reason.
Misconfigured func(reason, form string)
}
Alarms is how the check tells the administrators.
type Challenge ¶
type Challenge struct {
Algorithm string `json:"algorithm"`
Salt string `json:"salt"`
Hash string `json:"hash"`
Signature string `json:"signature"`
MaxNumber int `json:"maxnumber"`
}
Challenge is what the browser is given to solve.
type Config ¶
type Config struct {
Provider string
SiteKey string
// Secret is the provider's secret, opened. Never leaves the server.
Secret string
// SecretUnreadable: the panel holds a secret that does not open (the
// encryption key changed). It does not fall back to the environment:
// a broken row must not be hidden.
SecretUnreadable bool
Forms []string
Fallback string
VerifyTimeout time.Duration
// Sources names, per field, the layer it came from.
Sources map[string]string
}
Config is the bot check in force: every field resolved panel → environment → default.
func (Config) Enabled ¶
Enabled reports whether a provider is chosen. A missing key does not turn the check off: that is a misconfiguration, answered with the local fallback and an alarm.
func (Config) FormEnabled ¶
FormEnabled reports whether form is protected.
func (Config) Misconfiguration ¶
Misconfiguration names why a chosen provider cannot be used, or "".
type Consume ¶
Consume spends a solution: true the first time its signature is seen, false after (and when the store cannot tell). ttl is how long the mark must last.
type Fields ¶
type Fields struct {
CaptchaToken string `json:"captcha_token,omitempty"`
CfTurnstileResponse string `json:"cf-turnstile-response,omitempty"`
GRecaptchaResponse string `json:"g-recaptcha-response,omitempty"`
TurnstileToken string `json:"turnstile_token,omitempty"`
CaptchaLocal string `json:"captcha_local,omitempty"`
}
Fields are the bot-check fields of a form, in a JSON body or a form.
func FieldsFromForm ¶
FieldsFromForm reads the fields of a parsed form.
type Local ¶
type Local struct {
// Key is the HMAC key. Every instance that checks a challenge must hold
// the key that issued it.
Key []byte
// TTL is how long a challenge may be solved in; 0 = DefaultLocalTTL.
TTL time.Duration
// MaxNumber is the top of the range; 0 = DefaultMaxNumber.
MaxNumber int
// Now is the clock; nil = time.Now.
Now func() time.Time
}
Local issues and checks challenges.
func (*Local) IssueWith ¶
IssueWith makes the challenge for a given salt prefix, number and time (tests; Issue picks them at random).
type LocalResult ¶
type LocalResult struct {
OK bool
// Reason is "ok" or the first step that refused.
Reason string
// Signature is the solution's signature, lowercase, when it was read.
Signature string
// Expires is the challenge's end, when it was read.
Expires time.Time
}
LocalResult is the answer to one solution.
type Marks ¶
type Marks interface {
// AddCaptchaMark writes key until expires; false when a mark with that key
// is still live at now (it was spent already).
AddCaptchaMark(ctx context.Context, key string, expires, now int64) (bool, error)
// PutCaptchaMark writes key until expires, over any mark it had.
PutCaptchaMark(ctx context.Context, key string, expires int64) error
// CaptchaMarkUntil answers the end of key's mark; ok false when none.
CaptchaMarkUntil(ctx context.Context, key string) (int64, bool, error)
// PruneCaptchaMarks deletes the marks that ended before now.
PruneCaptchaMarks(ctx context.Context, now int64) (int64, error)
}
Marks is the bot check's shared state: the outage flag and the once-only marks (a provider token, a local solution, an upload pass). In the database (captcha_marks, migration 00134; db.Store satisfies it) because several filex instances can share one: the instance that answered 409 and the one that receives the solution are not always the same.
Times are unix seconds.
type MemoryMarks ¶
type MemoryMarks struct {
// contains filtered or unexported fields
}
MemoryMarks keeps the marks in this process: a test, or a server built without a database.
func (*MemoryMarks) AddCaptchaMark ¶
func (*MemoryMarks) CaptchaMarkUntil ¶
func (*MemoryMarks) PruneCaptchaMarks ¶
func (*MemoryMarks) PutCaptchaMark ¶
type Patch ¶
type Patch struct {
Provider *string `json:"provider,omitempty"`
SiteKey *string `json:"site_key,omitempty"`
Secret *string `json:"secret,omitempty"`
ClearSecret bool `json:"clear_secret,omitempty"`
Forms *[]string `json:"forms,omitempty"`
ClearForms bool `json:"clear_forms,omitempty"`
Fallback *string `json:"fallback,omitempty"`
VerifyTimeoutMS *int `json:"verify_timeout_ms,omitempty"`
}
Patch is a change from the panel. A nil field is left as it is; an empty string (an empty list for Forms is a choice, nil is not) clears it, so the environment or the default applies again. Secret "" changes nothing; ClearSecret removes the stored one.
type Probe ¶
type Probe struct {
Reachable bool `json:"reachable"`
// SecretOK: the provider took the secret (it refused only the probe's
// token).
SecretOK bool `json:"secret_ok"`
Codes []string `json:"codes"`
// Misconfigured names a configuration the probe could not use.
Misconfigured string `json:"misconfigured,omitempty"`
Cause string `json:"cause,omitempty"`
}
Probe is what the reachability probe found.
type PublicConfig ¶
type PublicConfig struct {
Provider string `json:"provider"`
SiteKey *string `json:"site_key"`
Script *string `json:"script"`
Fallback string `json:"fallback"`
Forms []string `json:"forms"`
Misconfigured bool `json:"misconfigured"`
FallbackActive bool `json:"fallback_active"`
LocalField string `json:"local_field"`
TokenField string `json:"token_field"`
}
PublicConfig is what a form reads before it draws the widget (GET /api/captcha/config). No secret.
type Result ¶
type Result struct {
Status Status
// Reason says why (invalid), what passed it ("provider" / "local"), or
// what took the provider away (fallback, unavailable).
Reason string
// Challenge is the local challenge of a fallback.
Challenge *Challenge
}
Result is the answer to one form.
func (Result) HTTPStatus ¶
HTTPStatus is the status the refusal is answered with.
type SettingStore ¶
type SettingStore interface {
GetSetting(ctx context.Context, key string) (string, error)
UpsertSetting(ctx context.Context, key, value string) error
}
SettingStore is the settings table. db.Store satisfies it.
type Settings ¶
type Settings struct {
Store SettingStore
// Box seals the secret (FILEX_SECRET_KEY). Nil or keyless: the panel
// cannot store one; CAPTCHA_SECRET still works.
Box *secretbox.Box
// Env reads the environment; nil = os.Getenv.
Env func(string) string
// TTL is how long a read is reused; 0 = 5 seconds, negative = not at all
// (tests). Another instance's change is seen within it.
TTL time.Duration
// contains filtered or unexported fields
}
Settings reads and writes the bot check's settings.
func (*Settings) Apply ¶
Apply writes p and answers the names of the fields it changed (never a value: the audit row carries names only).
func (*Settings) Invalidate ¶
func (s *Settings) Invalidate()
Invalidate forgets the cached read (after a save on this instance).
type Status ¶
type Status string
Status is the kind of a Result.
const ( // StatusPassed: the provider or the local fallback said yes. StatusPassed Status = "passed" // StatusSkipped: the check is off, or this form is not protected. StatusSkipped Status = "skipped" // StatusInvalid: no token, a refused one, an expired one, a bad // solution → 422. StatusInvalid Status = "invalid" // StatusFallback: the provider is down or the configuration cannot work // → 409 with a local challenge. StatusFallback Status = "fallback" StatusUnavailable Status = "unavailable" )
The results.
type Stored ¶
type Stored struct {
Provider string `json:"provider"`
SiteKey string `json:"site_key"`
HasSecret bool `json:"has_secret"`
Forms []string `json:"forms"`
Fallback string `json:"fallback"`
VerifyTimeoutMS int `json:"verify_timeout_ms"`
}
Stored is what the panel holds, field by field ("" / nil = empty: the environment or the default applies). The secret is only said to be there.
type Verifier ¶
type Verifier struct {
Settings *Settings
Marks Marks
// Client calls siteverify; nil = a plain client (each call has its own
// timeout).
Client *http.Client
// Local issues and checks the fallback's challenges.
Local *Local
// PassKey signs the upload passes; nil = Local.Key.
PassKey []byte
Alarms Alarms
// Siteverify overrides a provider's address (tests).
Siteverify map[string]string
// Now is the clock; nil = time.Now.
Now func() time.Time
// contains filtered or unexported fields
}
Verifier is the check.
func (*Verifier) ChallengeAnswer ¶
ChallengeAnswer is POST /api/captcha/local/challenge: the status and the body. The browser asks when the provider's script did not load; the fallback is handed out only when the SERVER cannot reach the provider either (or the configuration cannot work) - a script an ad blocker stopped is not an outage.
func (*Verifier) FallbackActive ¶
FallbackActive reports whether the outage flag lives.
func (*Verifier) FallbackTotal ¶
FallbackTotal is how many local challenges this instance handed out.
func (*Verifier) ProbeWith ¶
ProbeWith asks the provider with cfg's values and a token it will refuse.