captcha

package
v0.60.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 26 Imported by: 0

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

View Source
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.

View Source
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.

View Source
const (
	ProviderNone      = "none"
	ProviderTurnstile = "turnstile"
	ProviderRecaptcha = "recaptcha"
)

The providers.

View Source
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.

View Source
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"
	// FormSharePassword is the PIN a protected link asks for: a share, an
	// 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`.

View Source
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.

View Source
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.

View Source
const (
	DefaultVerifyTimeout = 4 * time.Second
	MinVerifyTimeoutMS   = 1000
	MaxVerifyTimeoutMS   = 15000
)

The verify timeout's bounds and default.

View Source
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).

View Source
const (
	SourceDB      = "db"
	SourceEnv     = "env"
	SourceDefault = "default"
)

The layer a value came from (Config.Sources).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
const Algorithm = "SHA-256"

Algorithm is the only one there is.

View Source
const (
	// FlagFallback is the outage flag: while it lives, a local solution is
	// accepted.
	FlagFallback = "captcha:fallback:active"
)

The mark keys.

View Source
const PassHeader = "X-Captcha-Pass"

PassHeader carries the pass on the upload.

Variables

View Source
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.

Forms is every form, in the order the settings page lists them. It is also what is protected when nothing says otherwise.

Providers is every value `captcha.provider` takes.

Functions

func EncodeSolution

func EncodeSolution(c Challenge, number int64) string

EncodeSolution packs a solution the way the browser does.

func ExpiresOf

func ExpiresOf(salt string) (int64, bool)

ExpiresOf reads `expires` (unix seconds) from the salt's query; any other parameter is ignored, the last `expires` wins.

func FrameOrigin

func FrameOrigin(cfg Config) string

FrameOrigin is the origin the provider's widget frames, "" when the check is off: the pages' frame-src names it.

func HashFor

func HashFor(salt string, number int64) string

HashFor is sha256(salt + number), lowercase hex.

func ScriptFor

func ScriptFor(provider string) string

ScriptFor is the provider's script.

func SignatureFor

func SignatureFor(key []byte, hash string) string

SignatureFor is HMAC-SHA256(key, lowercase hash), lowercase hex.

func Solve

func Solve(c Challenge) int64

Solve finds the number by scanning, as the browser's worker does (tests, the CLI). -1 when there is none in range.

func TokenShapeOK

func TokenShapeOK(provider, token string) bool

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

func (c Config) Enabled() bool

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

func (c Config) FormEnabled(form string) bool

FormEnabled reports whether form is protected.

func (Config) Misconfiguration

func (c Config) Misconfiguration() string

Misconfiguration names why a chosen provider cannot be used, or "".

type Consume

type Consume func(signature string, ttl time.Duration) bool

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

func FieldsFromForm(get func(string) string) Fields

FieldsFromForm reads the fields of a parsed form.

func (Fields) Token

func (f Fields) Token() string

Token is the first provider token given.

type Invalid

type Invalid struct {
	Field string
	Min   int
	Max   int
}

Invalid is a refused value: Field names it.

func (*Invalid) Error

func (e *Invalid) Error() string

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) Issue

func (l *Local) Issue() (Challenge, error)

Issue makes a new challenge.

func (*Local) IssueWith

func (l *Local) IssueWith(randomHex string, number int64, now time.Time) Challenge

IssueWith makes the challenge for a given salt prefix, number and time (tests; Issue picks them at random).

func (*Local) Max

func (l *Local) Max() int

Max is the top of the range this instance issues and accepts.

func (*Local) Verify

func (l *Local) Verify(payload string, consume Consume) LocalResult

Verify checks a solution (the `captcha_local` value), steps 1 to 8 in their order; the first that fails names the reason. consume runs last, only for a solution that passed every other step.

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 NewMemoryMarks

func NewMemoryMarks() *MemoryMarks

NewMemoryMarks makes an empty set.

func (*MemoryMarks) AddCaptchaMark

func (m *MemoryMarks) AddCaptchaMark(_ context.Context, key string, expires, now int64) (bool, error)

func (*MemoryMarks) CaptchaMarkUntil

func (m *MemoryMarks) CaptchaMarkUntil(_ context.Context, key string) (int64, bool, error)

func (*MemoryMarks) Len

func (m *MemoryMarks) Len() int

Len is the number of marks held (tests).

func (*MemoryMarks) PruneCaptchaMarks

func (m *MemoryMarks) PruneCaptchaMarks(_ context.Context, now int64) (int64, error)

func (*MemoryMarks) PutCaptchaMark

func (m *MemoryMarks) PutCaptchaMark(_ context.Context, key string, expires int64) error

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.

func (Patch) Validate

func (p Patch) Validate() error

Validate checks p without writing anything.

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

func (r Result) HTTPStatus() int

HTTPStatus is the status the refusal is answered with.

func (Result) OK

func (r Result) OK() bool

OK reports whether the form may go on.

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

func (s *Settings) Apply(ctx context.Context, p Patch) ([]string, error)

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).

func (*Settings) Load

func (s *Settings) Load(ctx context.Context) Config

Load is the configuration in force.

func (*Settings) Stored

func (s *Settings) Stored(ctx context.Context) Stored

Stored reads the panel's own values.

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: the same, with the fallback off → 503.
	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

func (v *Verifier) ChallengeAnswer(ctx context.Context) (int, map[string]any)

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) Check

func (v *Verifier) Check(ctx context.Context, form string, f Fields, ip string) Result

Check judges one form.

func (*Verifier) Config

func (v *Verifier) Config(ctx context.Context) Config

Config is the configuration in force.

func (*Verifier) FallbackActive

func (v *Verifier) FallbackActive(ctx context.Context) bool

FallbackActive reports whether the outage flag lives.

func (*Verifier) FallbackTotal

func (v *Verifier) FallbackTotal() int64

FallbackTotal is how many local challenges this instance handed out.

func (*Verifier) IssuePass

func (v *Verifier) IssuePass(scope string) (string, time.Time)

IssuePass signs a pass for scope (what it may be spent on).

func (*Verifier) ProbeWith

func (v *Verifier) ProbeWith(ctx context.Context, cfg Config) Probe

ProbeWith asks the provider with cfg's values and a token it will refuse.

func (*Verifier) Public

func (v *Verifier) Public(ctx context.Context) PublicConfig

Public is the configuration a form reads.

func (*Verifier) SpendPass

func (v *Verifier) SpendPass(ctx context.Context, pass, scope string) bool

SpendPass checks a pass for scope and spends it: true once.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL