Skip to content

fix(auth): rate-limit login per IP, trusted proxies, 12-character passwords - #150

Merged
BaaRai merged 5 commits into
mainfrom
fix/login-rate-limit-hardening
Nov 5, 2026
Merged

BaaRai merged 5 commits into
mainfrom
fix/login-rate-limit-hardening

Conversation

@BaaRai

@BaaRai BaaRai commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Login rate limit, 12-character passwords, loopback default, quieter /api/health

Deployment condition: reverse proxies

If ARTEX runs behind a reverse proxy, you must do both of the following before upgrading.

  1. Configure the proxy to forward X-Forwarded-For. For nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.
  2. Set ARTEX_TRUSTED_PROXIES to the address ARTEX sees the proxy connect from:
    • nginx and ARTEX both on the host: 127.0.0.1/32;
    • nginx on the host, ARTEX in Docker: the Docker bridge gateway, from docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}', set in .env.

Without both, every client reaches ARTEX from the proxy's single IP and shares one rate-limit bucket. Anyone who fails 10 logins in a minute then blocks every user, the admin included, for 1 to 15 minutes, and the block keeps extending while they continue. Direct and SSH-tunnel setups need no change.

Changes

  • Login rate limit (POST /api/auth/login)
    • Each client IP gets 10 attempts per minute. The next attempt gets 429, a Retry-After header in seconds, and a JSON {"error": ...} body.
    • The first block lasts 1 minute. Each attempt while blocked, and each later block, doubles it, up to 15 minutes.
    • A successful login clears that IP's count. A blocked IP is answered before its password is checked, so a reset only happens outside a block.
    • There is no per-account lockout.
    • All state is in memory. A restart clears it, idle entries are swept after 15 minutes, and at most 10,000 IPs are tracked; when full, the least recently seen one is dropped.
  • Client IP
    • By default the client IP is the TCP peer address, and X-Forwarded-For is ignored.
    • With ARTEX_TRUSTED_PROXIES set (comma-separated CIDRs, empty by default), a request from a listed peer is handled differently. The header is read from the right, and the first hop that is not a trusted proxy is the client. Entries the client added further left are never used.
    • An invalid value stops the binary at startup.
    • docker-compose.yml passes the variable through, and .env.example lists it.
  • Passwords
    • New passwords need at least 12 characters, up from 8. This applies to POST /api/auth/init, POST /api/auth/change-password, the /setup page check and text, and reset-password.sh.
    • Login has no length check, so existing 8-character passwords still work.
  • Listen address: the binary's -addr default is now 127.0.0.1:8787 (was :8787). The Docker image passes -addr :8787 explicitly, so containers are unchanged.
  • GET /api/health: without a valid token it returns {"ok":true} only. With a token it returns ok, service and version as before; the web UI's top bar reads version and sends its token.
  • Docs and CI
    • The README reverse-proxy section covers X-Forwarded-For and both proxy layouts, and gives the default listen address.
    • The CI shell job also runs shellcheck on reset-password.sh.
    • The CHANGELOG has an Unreleased entry.

Tests

These run without PostgreSQL: the handler logic is reached through login(w, r, getSetting) with a stub settings read.

  • TestLoginRateLimitReturns429WithRetryAfter
  • TestLoginSuccessResetsCounter
  • TestLoginRateLimitIsPerIP
  • TestLoginRateLimitIgnoresForwardedFor
  • TestLoginRateLimitBehindTrustedProxy
  • TestClientIP
  • TestParseTrustedProxies
  • TestEightCharPasswordLogsInButIsRejectedAsNew
  • TestLoginLimiterSweepsIdleEntries
  • TestLoginLimiterCapsEntries
  • TestHealthHidesVersionWithoutToken
  • TestValidatePassword (updated)

Results:

  • go build ./... and go vet ./server/ ./cmd/artex/ pass.
  • The tests above pass with -race.
  • go test ./server/... without PostgreSQL fails only in the tests that need a database. That is the same list as on main.

Not verified locally

  • The web build. The /setup change is a literal change from 8 to 12.
  • The Docker gateway behaviour.
  • shellcheck on reset-password.sh, because shellcheck is not installed on the machine used.
  • The PostgreSQL-backed tests.

…default

- POST /api/auth/login: 10 attempts per minute per TCP peer IP
  (X-Forwarded-For ignored), then 429 with Retry-After. The block starts
  at 1 minute and doubles on each attempt while blocked and on each new
  block, capped at 15 minutes. A successful login clears the IP. No
  per-account lockout. In-memory, idle entries swept after 15 minutes,
  at most 10,000 IPs.
- New passwords (init, change-password, /setup page) need 12 characters.
  Login does not check length, so existing 8-character passwords work.
- The binary's -addr default is 127.0.0.1:8787. The Docker image passes
  -addr :8787 explicitly and is unchanged.
- GET /api/health returns {"ok":true} without a valid token; with one it
  still returns service and version for the web UI top bar.
… reset-password.sh

- ARTEX_TRUSTED_PROXIES (comma-separated CIDRs, empty by default). When
  the TCP peer is listed, the login limit reads X-Forwarded-For from the
  right and keys on the first hop that is not a trusted proxy; otherwise
  the header is ignored. Without it every client behind a reverse proxy
  shares one bucket and an attacker can block the admin. Passed through
  by docker-compose.yml and listed in .env.example.
- README: nginx example sends X-Forwarded-For; document the variable;
  the binary's default listen address is 127.0.0.1:8787.
- reset-password.sh refuses passwords under 12 characters, counted in
  characters like the server.
- README: ARTEX_TRUSTED_PROXIES for nginx and ARTEX on the host
  (127.0.0.1) and for nginx on the host with ARTEX in Docker (the bridge
  gateway, found with docker network inspect).
- CI shell job: run shellcheck on reset-password.sh too.
@BaaRai
BaaRai merged commit 4342440 into main Nov 5, 2026
3 checks passed
@BaaRai BaaRai mentioned this pull request Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant