Files
selfpost/internal/web/auth/limitkeys_test.go
T
mixandClaude Opus 5.5 668e299c69 postfix: no reload for a map that did not change; DNS re-checks limited (audit W6/M9)
Security audit 2026-10-17, findings W6/M9 (low), stage 4. Every
application, upstream, recipient or report-address save reloaded Postfix
through supervisord, and POST .../dns-recheck bypassed the five-minute
cache for up to ~14 resolver queries - both at whatever rate an
authenticated user pressed.

- writeChanged: each generated map is written only when its content
  differs from the file on disk, and Postfix is reloaded only when one was
  (sender map, inbound maps, DMARC maps). Health's Reload configuration
  ends with an explicit reload (app.Service.ReloadPostfix, SenderMaps
  gains Reload), so it still re-reads whatever is on disk. No debounce:
  with unchanged saves gone, a burst is real changes Postfix must see.
- auth.AllowDNSRecheck: twenty forced re-checks in 15 minutes per user;
  past that the outbound, inbound and settings re-check show the cached
  result without the "re-checked" flash, and the log says why.

Tests: TestARebuildThatChangesNothingDoesNotReload,
TestDNSRechecksArePerUser; the fakes gain Reload. Docs: guide § Overview
and Health; CHANGELOG; plan.

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

135 lines
4.7 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)
}
}
// A user forces the DNS checks twenty times in fifteen minutes, then sees
// the cached result (security audit 2026-10-17, M9); another user is not
// affected.
func TestDNSRechecksArePerUser(t *testing.T) {
m := testModule(t, false)
for i := 0; i < 20; i++ {
if !m.AllowDNSRecheck(1) {
t.Fatalf("re-check %d refused", i+1)
}
}
if m.AllowDNSRecheck(1) {
t.Error("the 21st re-check was allowed")
}
if !m.AllowDNSRecheck(2) {
t.Error("another user's first re-check was refused")
}
}