Verified the tree against docs/plans/code-review-2026-08.md and finished the tasks that were only partly done, plus two defects the pass introduced. - bcrypt: the setup account was still hashed at DefaultCost (10). The work factor now has one definition, auth.BcryptCost = 12, used everywhere. - rate limiter: protecting blocked buckets from eviction was unbounded — an attacker with many source addresses could grow the map by making each one fail its way to blocked. Blocked buckets are now evicted past maxBuckets * 4, nearest expiry first, logged once per window. Tests cover both halves. - postqueue: the 5s timeout left the output size unbounded; reads are now capped at 4 MiB and report truncation. - spec references: the ~76 `spec N` citations CHANGELOG [0.5.0] claimed were gone are removed from build/, deploy/, the first two migrations and the release workflow, with the affected comment paragraphs re-wrapped. Comments only; shell/YAML/SQL syntax re-checked. - schema-migrations.md: schema section was headed v9 with the chain at v10, and three links pointed at deleted plan files. - internal/configsafe: the shared empty/forbidden-character check behind the OpenDKIM and Postfix config writers; each writer keeps its own forbidden set. - dropped the parseDomainRateLimitForm/parseAppRateLimitForm pass-throughs left behind by the parser merge (only their own tests still called them). - plan doc now carries a status table: what shipped, what was already in place, what was declined (//go:build linux for the /proc reader) and the open CSRF-token question. Not done, each its own phase: typed template data structs, log/slog, DMARC service and handler tests, error-page/corrupted-backup tests, backup re-auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// Package configsafe holds the defence-in-depth check that every generated
|
||||
// configuration file shares: a value about to be written into a table line must
|
||||
// be non-empty and must not carry a character that could end the field, end the
|
||||
// line, or otherwise change what the file means (security.md).
|
||||
//
|
||||
// The forbidden set stays with the caller rather than living here, because what
|
||||
// breaks a line differs per format — a comma separates values in a Postfix map,
|
||||
// a colon separates fields in an OpenDKIM table — and the set belongs next to
|
||||
// the writer it protects, where a reviewer of that writer can see it.
|
||||
package configsafe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Token reports whether value is safe to interpolate into a config line. what
|
||||
// names the field for the error message ("domain", "login", …) and forbidden
|
||||
// lists the characters the format cannot carry.
|
||||
func Token(what, value, forbidden string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("empty %s", what)
|
||||
}
|
||||
if strings.ContainsAny(value, forbidden) {
|
||||
return fmt.Errorf("unsafe character in %s %q", what, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package configsafe
|
||||
|
||||
import "testing"
|
||||
|
||||
// The check is the last line of defence before a value reaches a config file,
|
||||
// so it must reject both halves of the problem: an empty value that would write
|
||||
// a malformed line, and any character from the caller's forbidden set.
|
||||
func TestToken(t *testing.T) {
|
||||
if err := Token("domain", "example.com", " \t\r\n,\\"); err != nil {
|
||||
t.Fatalf("clean value rejected: %v", err)
|
||||
}
|
||||
if err := Token("domain", "", " "); err == nil {
|
||||
t.Fatal("empty value accepted")
|
||||
}
|
||||
for _, bad := range []string{"a b", "a\tb", "a\rb", "a\nb", "a,b", "a\\b"} {
|
||||
if err := Token("domain", bad, " \t\r\n,\\"); err == nil {
|
||||
t.Errorf("Token(%q) = nil, want error", bad)
|
||||
}
|
||||
}
|
||||
// A character outside the caller's set is that format's business, not this
|
||||
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash.
|
||||
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
|
||||
t.Errorf("character outside the forbidden set rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/configsafe"
|
||||
"github.com/mixeme/selfpost/internal/supervisor"
|
||||
)
|
||||
|
||||
@@ -202,18 +203,23 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
||||
return []byte(kt.String()), []byte(st.String()), nil
|
||||
}
|
||||
|
||||
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
|
||||
// value: whitespace and the newline that would end the line, and the colon and
|
||||
// path separators that carry meaning inside a table entry.
|
||||
const opendkimForbidden = " \t\r\n:/\\"
|
||||
|
||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
||||
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
|
||||
// to a similar set before they reach here (security.md); this is defence in depth
|
||||
// against a validation gap ever letting whitespace, a newline or a field
|
||||
// separator through into a config file (security.md).
|
||||
func assertConfigSafe(domainName, selector string) error {
|
||||
for _, v := range []string{domainName, selector} {
|
||||
if v == "" {
|
||||
return fmt.Errorf("opendkim: empty domain or selector")
|
||||
}
|
||||
if strings.ContainsAny(v, " \t\r\n:/\\") {
|
||||
return fmt.Errorf("opendkim: unsafe character in %q", v)
|
||||
for _, f := range []struct{ what, value string }{
|
||||
{"domain", domainName},
|
||||
{"selector", selector},
|
||||
} {
|
||||
if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
|
||||
return fmt.Errorf("opendkim: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/configsafe"
|
||||
)
|
||||
|
||||
// InboundRoute is one inbound domain's Postfix map material: the relay
|
||||
@@ -115,13 +117,15 @@ func assertInboundRouteSafe(r InboundRoute) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// texthashForbidden is what may never appear in a texthash line: whitespace and
|
||||
// the newline that would end it, the comma that separates values, and the
|
||||
// backslash.
|
||||
const texthashForbidden = " \t\r\n,\\"
|
||||
|
||||
// assertMapToken rejects values that could break out of a texthash line.
|
||||
func assertMapToken(v, what string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("postfix: empty %s", what)
|
||||
}
|
||||
if strings.ContainsAny(v, " \t\r\n,\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in %s %q", what, v)
|
||||
if err := configsafe.Token(what, v, texthashForbidden); err != nil {
|
||||
return fmt.Errorf("postfix: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/configsafe"
|
||||
"github.com/mixeme/selfpost/internal/supervisor"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,15 @@ func appendUnique(list []string, v string) []string {
|
||||
return append(list, v)
|
||||
}
|
||||
|
||||
// What may never reach a sender-map line: whitespace and the newline that would
|
||||
// end it, the comma that separates values, the colon a lookup key cannot carry,
|
||||
// and the backslash. A login additionally may not carry "@" — that would make it
|
||||
// look like an address.
|
||||
const (
|
||||
senderMapAddressForbidden = " \t\r\n,:\\"
|
||||
senderMapLoginForbidden = " \t\r\n,:@\\"
|
||||
)
|
||||
|
||||
// assertMapSafe rejects any address/login value that could break out of a single
|
||||
// map line or inject a directive. Addresses are validated to a strict whitelist
|
||||
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
|
||||
@@ -130,14 +140,11 @@ func appendUnique(list []string, v string) []string {
|
||||
// letting whitespace, a newline or a comma (the value separator) through into
|
||||
// the file (security.md).
|
||||
func assertMapSafe(address, login string) error {
|
||||
if address == "" || login == "" {
|
||||
return fmt.Errorf("postfix: empty address or login")
|
||||
if err := configsafe.Token("address", address, senderMapAddressForbidden); err != nil {
|
||||
return fmt.Errorf("postfix: %w", err)
|
||||
}
|
||||
if strings.ContainsAny(address, " \t\r\n,:\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in address %q", address)
|
||||
}
|
||||
if strings.ContainsAny(login, " \t\r\n,:@\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in login %q", login)
|
||||
if err := configsafe.Token("login", login, senderMapLoginForbidden); err != nil {
|
||||
return fmt.Errorf("postfix: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
@@ -11,6 +12,35 @@ import (
|
||||
|
||||
const postqueueTimeout = 5 * time.Second
|
||||
|
||||
// maxQueueOutput bounds how much of `postqueue -p` the panel keeps. A deferred
|
||||
// queue with a very large backlog prints one paragraph per message, and the
|
||||
// whole listing is held in memory and then rendered into a page — so the read
|
||||
// is capped and the remainder reported as truncated rather than being allowed
|
||||
// to size the panel's memory by the size of the queue.
|
||||
const maxQueueOutput = 4 << 20
|
||||
|
||||
// limitWriter keeps the first remaining bytes written to it and drops the rest,
|
||||
// recording that it did. Writes always report full consumption so postqueue is
|
||||
// never killed by a short write; the extra output is simply discarded.
|
||||
type limitWriter struct {
|
||||
buf bytes.Buffer
|
||||
remaining int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||
keep := len(p)
|
||||
if keep > l.remaining {
|
||||
keep = l.remaining
|
||||
l.truncated = true
|
||||
}
|
||||
if keep > 0 {
|
||||
l.buf.Write(p[:keep])
|
||||
l.remaining -= keep
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Queue returns Postfix's own human-readable mail-queue listing
|
||||
// (architecture.md § Panel HTTP surface): active, deferred and held messages,
|
||||
// exactly as an administrator would see via the CLI. The command takes a
|
||||
@@ -21,14 +51,21 @@ func Queue() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "postqueue", "-p")
|
||||
out, err := cmd.CombinedOutput()
|
||||
out := &limitWriter{remaining: maxQueueOutput}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
err := cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("postqueue -p timed out after %s", postqueueTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(out.buf.String()))
|
||||
}
|
||||
return string(out), nil
|
||||
text := out.buf.String()
|
||||
if out.truncated {
|
||||
text += fmt.Sprintf("\n[listing truncated at %d bytes]\n", maxQueueOutput)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// QueueIDs returns the set of queue ids Postfix is still holding — everything
|
||||
|
||||
@@ -41,3 +41,34 @@ func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
|
||||
t.Errorf("got %v, want no ids", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// The queue listing is held in memory and rendered into a page, so a very large
|
||||
// queue must not size the panel's allocation: the writer keeps the cap and
|
||||
// reports the rest as dropped, while still accepting every write so postqueue
|
||||
// itself is never cut short.
|
||||
func TestLimitWriterCapsOutput(t *testing.T) {
|
||||
w := &limitWriter{remaining: 8}
|
||||
n, err := w.Write([]byte("12345"))
|
||||
if n != 5 || err != nil {
|
||||
t.Fatalf("first write = %d, %v", n, err)
|
||||
}
|
||||
if w.truncated {
|
||||
t.Fatal("truncated before the cap was reached")
|
||||
}
|
||||
n, err = w.Write([]byte("67890"))
|
||||
if n != 5 || err != nil {
|
||||
t.Fatalf("second write = %d, %v", n, err)
|
||||
}
|
||||
if !w.truncated {
|
||||
t.Fatal("write past the cap was not recorded as truncated")
|
||||
}
|
||||
if got := w.buf.String(); got != "12345678" {
|
||||
t.Fatalf("kept %q, want the first 8 bytes", got)
|
||||
}
|
||||
if n, err := w.Write([]byte("more")); n != 4 || err != nil {
|
||||
t.Fatalf("write after the cap = %d, %v (must still accept)", n, err)
|
||||
}
|
||||
if got := w.buf.String(); got != "12345678" {
|
||||
t.Fatalf("buffer grew past the cap: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
|
||||
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
|
||||
-- Initial SelfPost schema. One SQLite file under /data holds the whole panel
|
||||
-- state so a single directory backup/restore is sufficient.
|
||||
|
||||
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the
|
||||
-- presence of that row is what marks primary setup as complete, which is why
|
||||
-- the /setup route disappears once it exists.
|
||||
-- Single administrator account. Exactly one row is allowed; the presence of
|
||||
-- that row is what marks primary setup as complete, which is why the /setup
|
||||
-- route disappears once it exists.
|
||||
CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
@@ -17,8 +17,8 @@ CREATE TABLE settings (
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
|
||||
-- live on disk under /data; this row records the selector and metadata.
|
||||
-- Sending domains managed through the panel. DKIM keys themselves live on disk
|
||||
-- under /data; this row records the selector and metadata.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -26,8 +26,8 @@ CREATE TABLE domains (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
|
||||
-- wildcard or an explicit address list; the SASL login is globally unique.
|
||||
-- Applications bound to a domain. address_mode is either the domain wildcard or
|
||||
-- an explicit address list; the SASL login is globally unique.
|
||||
CREATE TABLE applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE applications (
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
-- belong to the application's domain (validated in the panel, spec 7.6.2).
|
||||
-- belong to the application's domain (validated in the panel).
|
||||
CREATE TABLE application_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
@@ -45,8 +45,8 @@ CREATE TABLE application_addresses (
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
|
||||
-- log-tailer advances status from queued to a final state.
|
||||
-- Structured send log. One row per (queue-id, recipient); the log-tailer
|
||||
-- advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id TEXT,
|
||||
@@ -63,8 +63,8 @@ CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
|
||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
||||
|
||||
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP
|
||||
-- binding and the message limit are optional.
|
||||
-- Differentiated rate limits per domain/application. Both the IP binding and
|
||||
-- the message limit are optional.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a
|
||||
-- container restart or redeploy; only the SHA-256 of the session token is
|
||||
-- stored, never the token itself, so a stolen database file cannot be used to
|
||||
-- sign in. expires_at implements the sliding idle timeout: it is pushed
|
||||
-- forward on activity rather than being fixed at creation time.
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -15,6 +15,13 @@ const (
|
||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||
)
|
||||
|
||||
// BcryptCost is the work factor for every panel password hash — the setup
|
||||
// account created here and the ones the settings and user pages write
|
||||
// (security.md). bcrypt records the cost inside the hash, so raising it only
|
||||
// affects passwords set from now on; existing hashes keep verifying at the
|
||||
// cost they were written with.
|
||||
const BcryptCost = 12
|
||||
|
||||
func (m *Module) sessionCookie() string {
|
||||
if m.cfg.CookieSecure {
|
||||
return sessionCookiePrefixed
|
||||
@@ -219,7 +226,7 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
logf("panel: setup: hashing password failed: %v", err)
|
||||
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
|
||||
@@ -7,6 +7,16 @@ import (
|
||||
|
||||
const defaultMaxBuckets = 4096
|
||||
|
||||
// blockedBucketCeiling is how far past maxBuckets the map may grow while every
|
||||
// bucket in it is blocked. Blocked buckets are normally protected from
|
||||
// eviction (see evictNearest), but protecting them unconditionally would let
|
||||
// an attacker with many source addresses grow the map without bound — each
|
||||
// address only has to fail max times. Past the ceiling blocked buckets are
|
||||
// evicted too, nearest expiry first: bounded memory wins over holding every
|
||||
// block, and the addresses that lose their bucket have to spend max attempts
|
||||
// again to get back to blocked.
|
||||
const blockedBucketCeiling = 4
|
||||
|
||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||
// setup and login routes (security.md). Keys are client IPs.
|
||||
type rateLimiter struct {
|
||||
@@ -14,8 +24,9 @@ type rateLimiter struct {
|
||||
window time.Duration
|
||||
maxBuckets int
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
lastOverflow time.Time
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
@@ -68,22 +79,38 @@ func (r *rateLimiter) makeRoom(now time.Time) {
|
||||
return
|
||||
}
|
||||
r.sweep(now)
|
||||
hardCap := r.maxBuckets * blockedBucketCeiling
|
||||
for len(r.buckets) >= r.maxBuckets {
|
||||
if !r.evictNearest() {
|
||||
if r.evictNearest(false) {
|
||||
continue
|
||||
}
|
||||
// Nothing evictable is left: every remaining bucket is blocked. Hold
|
||||
// them until the map reaches the hard ceiling, then start evicting
|
||||
// blocked buckets as well so memory stays bounded.
|
||||
if len(r.buckets) < hardCap {
|
||||
break
|
||||
}
|
||||
if !r.evictNearest(true) {
|
||||
break
|
||||
}
|
||||
if now.Sub(r.lastOverflow) >= r.window {
|
||||
r.lastOverflow = now
|
||||
logf("panel: login rate limiter holds %d blocked buckets (cap %d) — evicting blocked entries; "+
|
||||
"this means many distinct client addresses are failing authentication", len(r.buckets), r.maxBuckets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictNearest removes the bucket whose window ends soonest, skipping buckets
|
||||
// that are currently blocked (count >= max) so an attacker cannot flush active
|
||||
// rate-limit state by filling the map with new keys.
|
||||
func (r *rateLimiter) evictNearest() bool {
|
||||
// evictNearest removes the bucket whose window ends soonest and reports whether
|
||||
// it removed one. Blocked buckets (count >= max) are skipped unless evictBlocked
|
||||
// is set, so an attacker cannot flush active rate-limit state by filling the map
|
||||
// with new keys.
|
||||
func (r *rateLimiter) evictNearest(evictBlocked bool) bool {
|
||||
var bestKey string
|
||||
var bestEnds time.Time
|
||||
first := true
|
||||
for k, b := range r.buckets {
|
||||
if b.count >= r.max {
|
||||
if !evictBlocked && b.count >= r.max {
|
||||
continue
|
||||
}
|
||||
if first || b.windowEnds.Before(bestEnds) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -123,3 +124,53 @@ func TestRateLimiterCapsBucketCount(t *testing.T) {
|
||||
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
|
||||
}
|
||||
}
|
||||
|
||||
// A flood of new addresses must not be able to reset the limiter for an address
|
||||
// that is already blocked: below the hard ceiling, blocked buckets survive
|
||||
// eviction even though the map is over its soft cap.
|
||||
func TestRateLimiterKeepsBlockedBucketsUnderPressure(t *testing.T) {
|
||||
r := newRateLimiter(2, time.Minute)
|
||||
r.maxBuckets = 3
|
||||
|
||||
blocked := []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"}
|
||||
for _, key := range blocked {
|
||||
for i := 0; i < 2; i++ {
|
||||
if !r.Allow(key) {
|
||||
t.Fatalf("%s refused before reaching the limit", key)
|
||||
}
|
||||
}
|
||||
if r.Allow(key) {
|
||||
t.Fatalf("%s was not blocked after 2 attempts", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh addresses arrive and find nothing evictable.
|
||||
for i := 0; i < 10; i++ {
|
||||
r.Allow(fmt.Sprintf("198.51.100.%d", 100+i))
|
||||
}
|
||||
|
||||
for _, key := range blocked {
|
||||
if r.Allow(key) {
|
||||
t.Fatalf("%s lost its block after new addresses arrived", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protecting blocked buckets is bounded: past maxBuckets * blockedBucketCeiling
|
||||
// they are evicted too, so an attacker cannot grow the map without limit by
|
||||
// burning through source addresses.
|
||||
func TestRateLimiterBoundsBlockedBuckets(t *testing.T) {
|
||||
r := newRateLimiter(1, time.Minute)
|
||||
r.maxBuckets = 2
|
||||
hardCap := r.maxBuckets * blockedBucketCeiling
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
r.Allow(fmt.Sprintf("192.0.2.%d", i))
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.buckets) > hardCap {
|
||||
t.Fatalf("bucket count = %d, want at most the hard cap %d", len(r.buckets), hardCap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,14 +98,6 @@ func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
return parseRateLimitForm(r, l1Max)
|
||||
}
|
||||
|
||||
func parseAppRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
return parseRateLimitForm(r, l1Max)
|
||||
}
|
||||
|
||||
func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false, nil, fmt.Errorf("invalid form submission")
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDomainRateLimitForm(t *testing.T) {
|
||||
// Domain and application limits are parsed by the same function; the form
|
||||
// fields and the level-1 backstop are identical for both scopes.
|
||||
func TestParseRateLimitForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
form := func(vals url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||
@@ -16,7 +18,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
|
||||
return r
|
||||
}
|
||||
|
||||
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||
in, err := parseRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"50"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -25,44 +27,12 @@ func TestParseDomainRateLimitForm(t *testing.T) {
|
||||
t.Fatalf("valid domain = %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||
in, err = parseRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||
if err != nil || !in.clear {
|
||||
t.Fatalf("empty max should clear: %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||
t.Fatalf("over L1 want error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppRateLimitForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
form := func(vals url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
in, err := parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"80"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
if err != nil || in.maxMessages != 80 {
|
||||
t.Fatalf("valid app limit = %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
in, err = parseAppRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||
if err != nil || !in.clear {
|
||||
t.Fatalf("empty max should clear: %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
_, err = parseRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -99,21 +69,21 @@ func TestParseAppAuthIPsForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainRateLimitFormAuto(t *testing.T) {
|
||||
func TestParseRateLimitFormAuto(t *testing.T) {
|
||||
t.Parallel()
|
||||
form := func(vals url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||
in, err := parseRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"2.5"},
|
||||
}), 100)
|
||||
if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 {
|
||||
t.Fatalf("auto domain = %+v err=%v", in, err)
|
||||
}
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
_, err = parseRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"10"},
|
||||
}), 100)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package handlers
|
||||
|
||||
const bcryptCost = 12
|
||||
import "github.com/mixeme/selfpost/internal/web/auth"
|
||||
|
||||
// bcryptCost is the panel-wide password work factor; see auth.BcryptCost for
|
||||
// why it lives in one place.
|
||||
const bcryptCost = auth.BcryptCost
|
||||
|
||||
Reference in New Issue
Block a user