loginguard

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: 22 Imported by: 0

Documentation

Overview

Package loginguard limits how many wrong sign-in attempts an account and an address may make, for every door a password can be typed at: the web form, WebDAV, FTPS, SFTP.

What is counted, and where

Two counters, held in MEMORY and written through to the database (login_throttle, migration 00072) on every change:

  • per ACCOUNT IDENTIFIER: the normalized text the caller typed (identity.Normalize), whether or not an account by that name exists. The answer to "how many tries are left" is therefore the same for a real name and a made-up one — the limit is not an account-enumeration oracle;
  • per ADDRESS: the client address (internal/clientip).

Every decision is read from memory; the table is what a restart or a deploy comes back to (Load fills the memory from it at start), so neither hands an attacker a fresh set of guesses. A write the table refuses (full disk, lost connection) does NOT change the decision — the limit goes on from memory, neither failing open nor locking everyone out — and the counter is written again at the next write that succeeds, or at the next tick.

The settings (the switch, the numbers, the allow-list, the trusted proxies) are held the same way: read from the table at start, written to the table AND put in force in memory on every save (SaveSetting), so a change applies to the next attempt — and read from the table again every minute (Tick), so a change saved on another instance is in force here within a minute. A settings read the table refuses keeps the last value known (the default when there was none).

The housekeeping runs on its own clock, not on the traffic: Run (started with the server) ticks every minute — the settings are read again, locks that ran out are released and audited, idle counters leave memory, owed writes are retried and the table is pruned.

⚠ The COUNTERS are per PROCESS. Instances sharing one database each count in their own memory (the table is read once, at Load): with N instances a guesser spread over all of them gets up to N times the limit per account, a lock placed by one is not seen by the others until they restart, and the table holds whichever instance wrote a counter last. The settings are shared (above). Run the limit on one instance, or in front of all of them, if that matters.

The guard sits ABOVE the driver chain. Local, LDAP, recovery and any provider added later are judged behind it; none of them has to know it exists, and none of them can forget it.

The rules

  • A subject that reaches its limit inside the window is LOCKED. The first lock lasts login.lock_base_seconds; each further lock in a row doubles it up to login.lock_max_seconds. The doubling forgets after a quiet spell and after a success.
  • While a lock is in force even the RIGHT password is refused, and the refused attempt is not counted. A lock the correct answer lifts is no lock at all against somebody working through the space.
  • An address on login.ip_allowlist is exempt from the per-address limit, and may sign in to any locked account (with the right password): the lock is not applied to that address. Its wrong attempts are audited but counted against nobody. NO account is privileged — the bootstrap administrator is limited like everyone else, and the way back in is an allow-listed address. The one exception is a public demo's SHARED account (FILEX_DEMO_MODE): its password is published, so the per-account lock on it would be a switch any stranger flips for every visitor; it is counted per address only (exempt.go).
  • A success resets the ACCOUNT's counter. It does NOT reset the address's: otherwise one valid login between guesses would keep a spraying address under its limit for ever.

Known limits

Locking an account by typing its name wrongly is possible by design (that is what a lock is); the allow-list is the remedy, and the escalating lock keeps the cost of the attack on the attacker's side of the door. The memory holds at most MaxCounters counters: past that, the counters a sweep would drop go first, then the oldest — a counter with no lock in force before one with — so a flood of made-up names or addresses cannot grow it without bound.

Package loginguard — proxyseed.go

FILEX_TRUSTED_PROXIES is a SEED since 0.57 (task #258, the owner's decision of 2026-10-15), like every other setting a variable gives a first value to (package dbsetting):

a start where `login.trusted_proxies` holds nothing  → the configuration's
    list (FILEX_TRUSTED_PROXIES over config.yaml trusted_proxies) is
    written into it, as typed
from then on                                          → the stored list
    wins; the variable is not read again, and a start whose variable says
    something else logs it once
`filex settings reseed login.trusted_proxies`         → writes the
    configuration's list over the stored one, on purpose

Until 0.57 the variable was read at every start, as the fallback under an empty setting. An install upgraded with the variable set and no row (the usual case: the list was never edited on the page) gets that very list written at its first 0.57 start, so nothing it trusts changes - `auto` and a list such as "auto, <the proxy's address>" included.

⚠ The fallback under an empty row stays (Guard.TrustedProxySource): a start that could not write the seed, or a list removed on the page during this run, still trusts what the configuration says rather than falling to `auto` until the next start re-seeds it. "Remove the saved list" therefore means what it always meant: back to the configuration's list.

Index

Constants

View Source
const (
	ProtoWeb  = "web"
	ProtoDAV  = "dav"
	ProtoFTP  = "ftp"
	ProtoSFTP = "sftp"
	ProtoS3   = "s3"
)

The doors a password can be typed at, as the audit trail names them.

View Source
const (
	ActionFailed        = "login.failed"
	ActionLocked        = "login.locked"
	ActionUnlocked      = "login.unlocked"
	ActionAllowlistPass = "login.allowlist_pass"
)

Audit actions the guard writes. One spelling each, so an operator grepping the Audit page finds every event ("login." is the filter prefix).

View Source
const (
	ReasonCredentials = "invalid_credentials"
	ReasonTOTP        = "invalid_totp"
)

Why a wrong attempt was wrong — audit metadata, never shown to the caller.

View Source
const (
	KeyEnabled      = "login.throttle_enabled"
	KeyAccountMax   = "login.account_max_fails"
	KeyIPMax        = "login.ip_max_fails"
	KeyWindow       = "login.window_seconds"
	KeyLockBase     = "login.lock_base_seconds"
	KeyLockMax      = "login.lock_max_seconds"
	KeyIPAllowlist  = "login.ip_allowlist"
	KeyTrustedProxy = "login.trusted_proxies"
)

The settings an administrator edits (Sign-in security page; REST /api/admin/login-security). All live in the settings table and are held in memory: read at start (Guard.Load) and again every minute (Guard.Tick, so a change saved on another instance arrives within a minute), and on every save written to the table AND put in force at once (Guard.SaveSetting), so a change applies to the next attempt without a restart. A write that reaches the table some other way (the generic settings API) is picked up at once by Guard.Invalidate.

⚠ They are instance-wide: one row each, for every tenant. The admin handler is supertenant-only for that reason.

View Source
const ActionSettingsUpdate = "login_security.update"

ActionSettingsUpdate is the audit action of a change to these settings, whichever route made it (the Sign-in security page, the generic settings API) and whichever door (panel, API key, MCP): the sign-in trail reads it beside the `login.` family.

View Source
const (

	// DefaultMaxCounters is the memory's size when Guard.MaxCounters is 0.
	DefaultMaxCounters = 100_000
)
View Source
const ProxyEnvVar = "FILEX_TRUSTED_PROXIES"

ProxyEnvVar is the variable that seeds the trusted-proxy list.

Variables

View Source
var (
	// EnabledSetting is the kill switch. On by default; an operator who runs
	// filex behind something that already limits sign-ins switches it off.
	EnabledSetting = dbsetting.BoolSpec{Key: KeyEnabled, Default: true}
	// AccountMaxSetting is the wrong attempts one account identifier may make
	// inside the window before it is locked.
	AccountMaxSetting = dbsetting.IntSpec{Key: KeyAccountMax, Default: 5, Min: 1, Max: 1000, Unit: "attempts"}
	// IPMaxSetting is the same for one client address.
	IPMaxSetting = dbsetting.IntSpec{Key: KeyIPMax, Default: 10, Min: 1, Max: 10000, Unit: "attempts"}
	// WindowSetting is how long a wrong attempt counts (seconds).
	WindowSetting = dbsetting.IntSpec{Key: KeyWindow, Default: 600, Min: 10, Max: 86400, Unit: "seconds"}
	// LockBaseSetting is the first lock's length; each further lock in a row
	// doubles it (seconds).
	LockBaseSetting = dbsetting.IntSpec{Key: KeyLockBase, Default: 60, Min: 1, Max: 86400, Unit: "seconds"}
	// LockMaxSetting is the ceiling the doubling stops at (seconds).
	LockMaxSetting = dbsetting.IntSpec{Key: KeyLockMax, Default: 900, Min: 1, Max: 86400, Unit: "seconds"}
	// IPAllowlistSetting lists addresses and networks (CIDR) that are exempt
	// from the per-address limit AND may sign in to a locked account.
	IPAllowlistSetting = dbsetting.StringSpec{
		Key: KeyIPAllowlist, Default: "",
		Check: func(v string) error { _, err := ParseAllowlist(v); return err },
	}
	// TrustedProxiesSetting lists the reverse proxies whose X-Forwarded-For /
	// X-Real-IP filex believes: addresses, CIDR networks and the words
	// `auto`, `loopback`, `private`, `link-local` (clientip.ParseList); `none`
	// trusts no proxy. FILEX_TRUSTED_PROXIES (or config.yaml trusted_proxies)
	// SEEDS it on a start where it holds nothing (proxyseed.go, 0.57); from
	// then on this row is the list in force and the variable is not read
	// again. Empty = the configuration's list, and when there is none either,
	// `auto` (loopback, plus the container networks filex is attached to
	// minus their gateways).
	TrustedProxiesSetting = dbsetting.StringSpec{
		Key: KeyTrustedProxy, Default: "",
		Check: func(v string) error { _, err := clientip.ParseList(v); return err },
	}
)

Functions

func Countdown added in v0.54.0

func Countdown(lang, scope string) string

Countdown is the lock's sentence in lang with its `{wait}` left for the reader's clock: the sign-in form counts the lock down second by second (a sentence worded once would say "1 minute" for a whole minute), and fills `{wait}` with what is left - the words stay the server's (0.54 audit A8; the form used to keep its own copy of them).

func FieldOf

func FieldOf(key string) string

FieldOf is a setting key's field name (FieldOf(KeyAccountMax) is "account_max_fails"); "" for a key that is not one of these settings.

func IsSettingKey

func IsSettingKey(key string) bool

IsSettingKey reports whether key is one of the sign-in security settings.

func ParseAllowlist

func ParseAllowlist(text string) (*clientip.Set, error)

ParseAllowlist reads an IP allow-list: addresses and CIDR networks separated by commas, semicolons, spaces or newlines. Blank text is an empty list. Unlike the trusted-proxy list it takes no words: an allow-list of client addresses that could silently mean "every private address" is a hole.

func ProxyConfigSource added in v0.60.0

func ProxyConfigSource() string

ProxyConfigSource names where the configuration's list came from: the variable when it is set, else the config.yaml key.

func ReseedTrustedProxies added in v0.60.0

func ReseedTrustedProxies(ctx context.Context, st dbsetting.Store, configured string) (bool, error)

ReseedTrustedProxies writes configured over the stored list - `filex settings reseed login.trusted_proxies`. A configuration with no list leaves the stored one as it is: the list typed on the page (a CDN's ranges, a proxy on another machine) is not dropped to `auto` because a variable is absent. A running server puts the new list in force at its next settings read (within a minute). It answers whether it wrote.

func RetryAfterSeconds

func RetryAfterSeconds(d time.Duration) int

RetryAfterSeconds is the Retry-After header's value: whole seconds, at least 1.

func SameProxyList added in v0.60.0

func SameProxyList(a, b string) bool

SameProxyList reports whether two lists name the same entries, whatever their order, case and separators: "auto, 192.0.2.10" and "192.0.2.10,AUTO" are one list.

func SeedTrustedProxies added in v0.60.0

func SeedTrustedProxies(ctx context.Context, st dbsetting.Store, configured string) (bool, error)

SeedTrustedProxies writes configured (the configuration's list: cfg. TrustedProxies) into `login.trusted_proxies` on a start where the row holds nothing, and logs, once, a configured list the stored one overrules. Call it at start, before Guard.Load, so the memory starts with the seeded list. It answers whether the stored list overrules the configured one.

A row the store cannot read is left alone and its error returned: the fallback under an empty setting keeps the configuration's list in force.

func Subject

func Subject(identifier string) string

Subject is the counter key for an identifier: normalized, and shortened with a hash when long. Exported because the admin surface names counters by it.

An operating-system account can be typed several ways (`alex`, `.\alex`, `CORP\alex`); they are ONE account, so they share one counter — otherwise somebody guessing a password could take a fresh allowance with every spelling. Only the `DOMAIN\` / `.\` prefix is dropped. The `name@host` form is left as it is, exactly as identity.Resolve reads it: an e-mail address and an operating-system name can collide, and folding the local part would let one person's failures lock another's account.

A realm (multi-tenant) leads the key: `acme/alex`. It is read from a `realm/` prefix too, so the key an administrator copies from the locks list unlocks exactly that counter, and the domain prefix is dropped AFTER it (`acme/CORP\alex` is `acme/alex`, not `alex`).

func SubjectIn

func SubjectIn(realm, identifier string) string

SubjectIn is Subject for an attempt in a realm: `<realm>/<subject>`; with no realm, the identifier's own `realm/` prefix, if it has one (see Subject).

Types

type Attempt

type Attempt struct {
	// Identifier is what the caller typed as the account name (e-mail or
	// username), exactly as typed.
	Identifier string
	// Realm is the tenant realm the attempt was for on a multi-tenant install
	// (typed, or named by the address — auth.LoginRealm.CounterRealm); "" for
	// the platform's own realm and on a single-tenant install. It is part of
	// the account counter: `acme/alex`, `beta/alex` and `alex` are three
	// people, and one of them guessing wrong must not lock the others.
	Realm string
	// IP is the client address; "" when it could not be told.
	IP string
	// Protocol is one of the Proto* doors.
	Protocol string
}

Attempt is one try at signing in.

type Config

type Config struct {
	Enabled    bool
	AccountMax int
	IPMax      int
	Window     time.Duration
	LockBase   time.Duration
	LockMax    time.Duration
	Allow      *clientip.Set
}

Config is every number the guard decides with.

func LoadConfig

func LoadConfig(ctx context.Context, g dbsetting.Getter) Config

LoadConfig resolves the settings from g.

type ExemptFunc

type ExemptFunc func(ctx context.Context) ([]string, error)

ExemptFunc answers the account counter keys (SubjectIn form) the per-account limit does not apply to. An error keeps the last answer in force; the first time, what came back with it is used.

func DemoAccount

func DemoAccount(store db.Store, identifier, emailToken string) ExemptFunc

DemoAccount is the ExemptFunc of a public demo: the account identifier (FILEX_DEMO_USER - the name the demo page's button signs in with) names, looked up as a sign-in looks it up (identity.Resolve), exempt by its e-mail, its username, the login name a made-up address was derived from (emailToken is the installation's FILEX_OS_LOGIN_EMAIL_TOKEN) and the identifier as configured - each in the account's own realm (its tenant's; "" for the platform's). An identifier that names no account yet is exempt as written, in the platform's realm, until the account exists. A blank one exempts nobody.

type Guard

type Guard struct {
	Store db.Store
	// Now is the clock; nil = time.Now. Tests move it.
	Now func() time.Time
	// MaxCounters bounds the counters held in memory; 0 = DefaultMaxCounters.
	MaxCounters int
	// Every is how often Run ticks; 0 = a minute.
	Every time.Duration
	// OnTick, when set, runs at the end of every Tick. The server hangs the
	// re-resolution of the automatic trusted-proxy set on it
	// (clientip.RefreshAuto): the list the guard's settings name may say
	// `auto`, and a container can join a network at run time.
	OnTick func(context.Context)
	// contains filtered or unexported fields
}

Guard is the sign-in limiter.

func New

func New(store db.Store) *Guard

New builds a Guard over store. It reads the table the first time it is used; call Load at start to do that before anything is served.

func (*Guard) Check

func (g *Guard) Check(ctx context.Context, a Attempt) Verdict

Check decides, BEFORE any password is compared, whether the attempt may go on. A blocked attempt is refused whatever it carries. It reads memory only.

func (*Guard) Config

func (g *Guard) Config(ctx context.Context) Config

Config returns the settings in force (from memory).

func (*Guard) ExemptAccounts

func (g *Guard) ExemptAccounts(fn ExemptFunc)

ExemptAccounts installs fn (nil removes the exemption). Safe to call while the guard is running.

func (*Guard) Failed

func (g *Guard) Failed(ctx context.Context, a Attempt, reason string) Outcome

Failed records a wrong attempt and answers what to tell the caller. reason is one of the Reason* constants (audit only).

The same code runs for an identifier that names no account: the counter is keyed by the text, not by a user row.

func (*Guard) Invalidate

func (g *Guard) Invalidate()

Invalidate reads the settings from the table again, now. It is for a write that reached the table without SaveSetting (the generic settings API, a test); a key the table cannot answer keeps its last known value.

func (*Guard) List

func (g *Guard) List(ctx context.Context, scope string, lockedAt *time.Time, limit int) []*model.LoginThrottle

List answers the counters in memory — the ones deciding right now — the most recently active first: one scope ("" = both), only the locks in force at lockedAt when it is non-nil, at most limit (<= 0: 200; never more than 1000). The rows are copies.

func (*Guard) Load

func (g *Guard) Load(ctx context.Context) error

Load reads the settings and the counters from the table into memory. It is called once at start; a table that cannot be read is reported (and logged), the guard runs on the last known settings (the defaults) and an empty memory, and the counters are read again at the next sweep — merged under what the memory has counted since.

func (*Guard) Run

func (g *Guard) Run(ctx context.Context)

Run is the housekeeping loop: every Every (a minute) it runs Tick, until ctx ends. The server starts it once, at start (server.go). No sign-in decision waits on it, and no sign-in attempt runs it: a quiet instance is swept as often as a busy one.

func (*Guard) SaveSetting

func (g *Guard) SaveSetting(ctx context.Context, key, value string) error

SaveSetting writes one sign-in security setting to the table and, once the table has taken it, puts it in force: the next attempt decides with it. A write the table refuses changes nothing and is returned, so the memory and the table never disagree about a setting.

func (*Guard) Stored

func (g *Guard) Stored(ctx context.Context) dbsetting.Getter

Stored answers the settings table's text for the sign-in security keys as the memory holds it — what the admin surface shows as "saved".

func (*Guard) Succeeded

func (g *Guard) Succeeded(ctx context.Context, a Attempt)

Succeeded records a correct sign-in. A normal success resets the ACCOUNT's counter (never the address's). A success from an allow-listed address that only got in because of the allow-list — the account or address was locked — is audited as such, and changes no counter: the lock is not applied to that address, it is not lifted for everybody else.

func (*Guard) Sweep

func (g *Guard) Sweep(ctx context.Context)

Sweep does the housekeeping now: it releases the locks that ran out (auditing each), drops from memory the counters idle for a day, reads the table's counters if they could not be read at Load, writes what the table is owed, and prunes the table. Tick runs it every minute; the admin lock list runs it too, so what it shows and the audit trail agree.

func (*Guard) Tick

func (g *Guard) Tick(ctx context.Context)

Tick is one turn of the loop. The SETTINGS are read from the table again — a change saved on another instance sharing the database is in force here from this tick on, so a change reaches every instance within a minute; a read the table refuses keeps the last value known. Then Sweep. The COUNTERS are not read again: they are this instance's own (Load reads them once). Last, OnTick (the automatic trusted-proxy set is worked out again).

func (*Guard) TrustedProxySource

func (g *Guard) TrustedProxySource(env string) func() *clientip.Set

TrustedProxySource builds the function clientip.SetSource wants: the `login.trusted_proxies` setting in memory when it holds a list, else env (the configuration's list: FILEX_TRUSTED_PROXIES over config.yaml - the fallback for a start that could not seed it, proxyseed.go), else nil (clientip's default: `auto`). It is asked once per request and reads memory only; a saved change is in force for the next request.

func (*Guard) Unlock

func (g *Guard) Unlock(ctx context.Context, scope, subject string) (bool, error)

Unlock lifts a lock and clears the counter (an administrator's action; the caller writes the audit row through the request's audit detail). It reports whether there was a counter — in memory, or in the table. The lock is lifted in memory whatever the table answers; a delete the table refuses is retried.

func (*Guard) UnlockAll

func (g *Guard) UnlockAll(ctx context.Context) int

UnlockAll lifts every lock in force — in memory, and any the table holds that the memory does not (a counter this process let go of) — and answers how many.

type Outcome

type Outcome struct {
	// Locked: this failure (or a lock already running) shuts the door now.
	Locked bool
	// Scope is the counter the message speaks about: the one with fewer tries
	// left (the account on a tie), or the one that locked.
	Scope string
	// Remaining is the tries left before that counter locks (0 when Locked).
	Remaining int
	// Limit is the failure number at which it locks.
	Limit int
	// RetryAfter is how long the lock lasts, when Locked.
	RetryAfter time.Duration
	// Unlimited: nothing is counted for this attempt (guard off, or an
	// allow-listed address), so there is no count to report.
	Unlimited bool
}

Outcome is Failed's answer: what the caller is told about the attempt that just failed.

func (Outcome) Message

func (o Outcome) Message(lang string) string

Message is what to tell the caller about a failed attempt: how many tries are left and at which failure the lock falls — or, when this failure locked, when it opens.

type Verdict

type Verdict struct {
	// Blocked: a lock is in force for this attempt. The password must not be
	// checked, and the attempt is not counted.
	Blocked bool
	// Scope names the lock that blocks: model.LoginThrottleAccount or
	// model.LoginThrottleIP.
	Scope string
	// RetryAfter is how long until it lifts.
	RetryAfter time.Duration
	// Allowlisted: the address is on the allow-list.
	Allowlisted bool
	// Disabled: the guard is switched off; nothing is counted.
	Disabled bool
}

Verdict is Check's answer: may this attempt be judged at all?

func (Verdict) Message

func (v Verdict) Message(lang string) string

Message is what to tell a caller whose attempt was refused because a lock is in force.

Jump to

Keyboard shortcuts

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