Files
selfpost/internal/web/auth/limitkeys_test.go
T
mixandClaude Opus 5.5 3a8e19b71c auth: sign-in limits per address and name; successes free (audit A1, A8, A9)
Security audit 2026-10-17, findings A1 (medium), A8, A9 (low), stage 3.
Under the shipped Apache-on-host deployment with TRUSTED_PROXY_CIDR empty,
every visitor has the Docker gateway's address, and every public limit was
keyed on the address alone: ten POSTs to /login per 15 minutes (any body,
successes counted) locked out every administrator; likewise /login/reset,
the link pages and /setup/. IPv6 was keyed per /128.

Now (internal/web/auth/limitkeys.go):
- sign-in counts per address and username (10 / 15 min) and per address
  alone at 100; a right password refunds both (rateLimiter.Refund);
- a reset request counts per address and username (5) and per address
  alone (50); a link's page per link, by token hash (30), and per address
  alone (300); setup per address as before;
- an IPv6 address is keyed by its /64 (limitAddr);
- proxyWatch: when the first 20 requests after a start all come from one
  private or loopback peer with X-Forwarded-For and no proxy is trusted,
  the log says so once with the TRUSTED_PROXY_CIDR value to set;
- a refused sign-in or a lockout is logged, five lines per address in 15
  minutes, the typed name printable and cut (refusalLog, logName).
The shipped compose cannot know the gateway, so the guide's install step
prints it (docker network inspect) and says to set it.

Tests: TestLoginBehindOneUntrustedProxyKeepsOthersIn (the reader's case),
TestLoginSuccessesDoNotCount, TestIPv6IsLimitedPerSlash64,
TestProxyWatchAndRefusalsAreLogged; the existing lockout tests pass.

Docs: security.md § First-run setup, § Authentication; guide § Full
deployment; CHANGELOG; plan. Also the I1 plan line's Done note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:47 +03:00

117 lines
4.2 KiB
Go

package auth
import (
"log"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
)
// Behind a reverse proxy the panel does not trust, every visitor has the
// proxy's address. A stranger there who guesses one name, or sprays many,
// must not lock out an administrator signing in from behind the same proxy
// (security audit 2026-10-17, A1: the reader's test, two clients behind one
// untrusted peer).
func TestLoginBehindOneUntrustedProxyKeepsOthersIn(t *testing.T) {
m := moduleWithAdmin(t)
const proxy = "172.18.0.1:40000"
for i := 0; i < 15; i++ {
postLogin(m, proxy, "admin2", "guess")
}
if rec := postLogin(m, proxy, "admin", testPassword); rec.Code != http.StatusSeeOther {
t.Fatalf("the administrator behind the same proxy = %d after a stranger's guesses at another name", rec.Code)
}
// Guessing the administrator's own name locks that name from there,
// not the others.
for i := 0; i < 10; i++ {
postLogin(m, proxy, "admin", "guess")
}
if rec := postLogin(m, proxy, "admin", testPassword); rec.Code != http.StatusTooManyRequests {
t.Errorf("the guessed name after ten failures = %d, want 429", rec.Code)
}
// Spraying names: the address alone is stopped at a hundred failures.
m = moduleWithAdmin(t)
for i := 0; i < 100; i++ {
postLogin(m, "203.0.113.50:1", "spray"+strings.Repeat("x", i%40)+string(rune('a'+i%26)), "guess")
}
if rec := postLogin(m, "203.0.113.50:1", "admin", testPassword); rec.Code != http.StatusTooManyRequests {
t.Errorf("after a hundred failures from one address = %d, want 429", rec.Code)
}
}
// A right password is no guess: a day of signing in uses up no limit
// (security audit 2026-10-17, A1 (c)).
func TestLoginSuccessesDoNotCount(t *testing.T) {
m := moduleWithAdmin(t)
for i := 0; i < 25; i++ {
if rec := postLogin(m, "203.0.113.7:1", "admin", testPassword); rec.Code != http.StatusSeeOther {
t.Fatalf("sign-in %d = %d", i+1, rec.Code)
}
}
}
// An IPv6 client is one client per /64: rotating addresses inside it
// gives no fresh limit (security audit 2026-10-17, A9).
func TestIPv6IsLimitedPerSlash64(t *testing.T) {
m := moduleWithAdmin(t)
for i := 0; i < 10; i++ {
postLogin(m, "[2001:db8:1:2::"+strconv.Itoa(100+i)+"]:443", "admin", "guess")
}
if rec := postLogin(m, "[2001:db8:1:2:ffff::1]:443", "admin", testPassword); rec.Code != http.StatusTooManyRequests {
t.Errorf("another address in the same /64 = %d, want 429", rec.Code)
}
if rec := postLogin(m, "[2001:db8:1:3::1]:443", "admin", testPassword); rec.Code != http.StatusSeeOther {
t.Errorf("another /64 = %d, want a sign-in", rec.Code)
}
if got := limitAddr(httptest.NewRequest(http.MethodGet, "/", nil), nil); got != "192.0.2.1" {
t.Errorf("an IPv4 address is keyed as %q", got)
}
}
// When every request comes from one private peer with X-Forwarded-For and no
// proxy is trusted, the log says so once, with the value to set; a request
// without the header ends the watch (security audit 2026-10-17, A1 (a)).
// Refusals are logged, a few per address (A8).
func TestProxyWatchAndRefusalsAreLogged(t *testing.T) {
var buf strings.Builder
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
var w proxyWatch
req := func(xff bool) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/login", nil)
r.RemoteAddr = "172.18.0.1:5000"
if xff {
r.Header.Set("X-Forwarded-For", "198.51.100.4")
}
return r
}
for i := 0; i < proxyWatchRequests+5; i++ {
w.see(req(true), nil)
}
if n := strings.Count(buf.String(), "set TRUSTED_PROXY_CIDR=172.18.0.1"); n != 1 {
t.Errorf("the warning was logged %d times:\n%s", n, buf.String())
}
buf.Reset()
var quiet proxyWatch
quiet.see(req(false), nil)
for i := 0; i < proxyWatchRequests+5; i++ {
quiet.see(req(true), nil)
}
if buf.Len() != 0 {
t.Errorf("a watch ended by a request without the header logged:\n%s", buf.String())
}
m := moduleWithAdmin(t)
for i := 0; i < 8; i++ {
postLogin(m, "203.0.113.9:1", "ad\x1bmin", "guess")
}
got := buf.String()
if n := strings.Count(got, "refused: wrong username or password"); n != 5 || !strings.Contains(got, `sign-in as "ad?min" from 203.0.113.9`) {
t.Errorf("refusals logged %d times (want 5), or the name unescaped:\n%s", n, got)
}
}