package middleware // import "git.griefed.de/griefed/repoman/internal/server/middleware" Package middleware contains repoman's HTTP middleware: - CSRF — cross-origin (CSRF) protection on state-changing requests - Auth — session lookup and user-context injection - RequireRole — fine-grained authorization checks per handler - RequestLog — structured access logging via slog CONSTANTS const SessionCookieName = "repoman_session" SessionCookieName is the name of the cookie holding the session ID. Hard-coded so the value is grep-friendly and never accidentally reconfigured to something a forgotten test still expects. VARIABLES var ( // LoginRateLimit: five attempts up front, then one every ten seconds. LoginRateLimit = RateLimitConfig{Burst: 5, RefillPerSecond: 0.1, MaxKeys: 16384, Event: security.EventRateLimited} // TwoFactorRateLimit is tighter than login on purpose: reaching it means the // password is already correct, so the six digits are the only thing left. TwoFactorRateLimit = RateLimitConfig{Burst: 5, RefillPerSecond: 0.05, MaxKeys: 16384, Event: security.EventRateLimited} // PasswordResetRateLimit guards the two most expensive anonymous paths: // issuing a token (an email send) and consuming one (a bcrypt comparison). PasswordResetRateLimit = RateLimitConfig{Burst: 3, RefillPerSecond: 0.016, MaxKeys: 16384, Event: security.EventRateLimited} // WebhookRateLimit guards the issue-hub ingress, which is anonymous and // CSRF-exempt because its callers are machines. Every request allocates up to // 4 MiB reading the body BEFORE the signature can be checked — and that // ordering is forced, since the HMAC is computed over the body — so an // unauthenticated client holding no secret at all still costs a 4 MiB // allocation and a SHA-256 pass. // // Far more generous than the credential limits, because a legitimate sender // is a forge delivering a burst of events for a busy repository, not a person // typing. 60 up front and 2/s sustained absorbs that while still bounding // what one source can spend. WebhookRateLimit = RateLimitConfig{Burst: 60, RefillPerSecond: 2, MaxKeys: 16384, Event: security.EventRateLimited} ) Defaults for the three credential endpoints. They are lenient enough for a shared office address (several people behind one NAT, occasional typos) and strict enough that sustained guessing is pointless. FUNCTIONS func Auth(opts AuthOptions) func(http.Handler) http.Handler Auth returns middleware that: 1. Looks up the session cookie 2. Loads the matching session and user from the repositories 3. Stores both in the request context for downstream handlers 4. Redirects to LoginPath when no valid session exists, EXCEPT for the configured AllowedAnonymousPaths 5. Redirects to PendingTwoFactorPath when the session has pending_2fa=1 (so the user cannot reach normal pages until they complete the second factor) Handlers retrieve the user via UserFromContext. func CSRF(opts CSRFOptions) func(http.Handler) http.Handler CSRF returns cross-origin request protection built on the standard library's net/http.CrossOriginProtection (Go 1.25+). Wrap any router that handles non-idempotent methods (POST, PUT, PATCH, DELETE). It rejects unsafe requests the browser reports as cross-site via the Sec-Fetch-Site header (falling back to an Origin/Host comparison when that header is absent), answering with 403 Forbidden. GET/HEAD/OPTIONS are always allowed, so the SSE stream and static assets are unaffected. Unlike a synchronizer-token scheme this needs no per-form hidden field, no signing key, and no cookie — which is why repoman dropped gorilla/csrf, whose host-only TrustedOrigins matching ignored the scheme (CVE-2025-47909, for which no upstream fix exists), in favour of the stdlib mechanism. func ClearSessionCookie(w http.ResponseWriter) ClearSessionCookie writes an immediately-expiring cookie of the same name so the browser drops its stale session ID. It is the single definition of that cookie's teardown, shared with the handlers package, because there were two copies and they had already drifted from the setter. Attributes are matched to setSessionCookie with ONE deliberate exception. HttpOnly and SameSite=Strict are carried over: neither is part of a cookie's identity (which is name + domain + path), so neither affects whether the deletion lands, and matching them keeps the two definitions honestly comparable. Secure is deliberately NOT set, and that is not an oversight — a Secure cookie is rejected outright by a browser on a plain-HTTP connection, so setting it here would make logout silently fail in exactly the localhost-dev deployment that runs without TLS, while omitting it deletes the cookie correctly under both. Deletion is not weakened by the omission: the Secure attribute governs whether a cookie is SENT, not whether it can be replaced. func ClientIP(r *http.Request) string ClientIP returns the remote IP of the client, taken from RemoteAddr and from nothing else. It is the single source of truth for "who is this request from", shared by the rate limiter and by the handlers that write the security log, so the throttle and the fail2ban record can never disagree about whom to blame. It deliberately does NOT read X-Forwarded-For or X-Real-IP. Those are trusted in exactly one place — chi's RealIP middleware, which the router installs ONLY under --behind-proxy, and which rewrites RemoteAddr from the trusted header. Reading RemoteAddr is therefore correct in both deployments, while reading the headers here would re-grant that trust in the one deployment where nobody has earned it: a directly-exposed daemon, where the client picks the header and so would pick both its own rate-limit bucket and the address fail2ban bans. A bare IPv6 literal must survive intact, which is why this is SplitHostPort and not a search for the last colon. func RequireRole(perm auth.Permission) func(http.Handler) http.Handler RequireRole returns middleware that lets only users with the given permission proceed. Unauthorised access returns 403; this is distinct from the 302-to-login path of Auth — by the time RequireRole runs, we KNOW the user is authenticated. func SessionFromContext(ctx context.Context) *auth.Session SessionFromContext retrieves the session from the request context. func UserFromContext(ctx context.Context) *auth.User UserFromContext retrieves the authenticated user from the request context. Returns nil if no user is attached (anonymous request or middleware was not wired). func WithSession(ctx context.Context, session *auth.Session) context.Context WithSession returns a copy of ctx carrying session as the active session. Test counterpart to WithUser; SessionFromContext will return session. func WithUser(ctx context.Context, user *auth.User) context.Context WithUser returns a copy of ctx carrying user as the authenticated user. The context keys are unexported, so handler/middleware tests in other packages use this to construct a request as if Auth had already run; UserFromContext will then return user. func NewActorContext(ctx context.Context) (context.Context, *requestActor) NewActorContext installs an empty actor holder on ctx and returns the augmented context together with the holder. The access logger calls this at the top of the chain, then reads Username() once the request has been served. TYPES type AuthOptions struct { Sessions *auth.SessionManager Users auth.UserRepository // LoginPath is where unauthenticated requests are redirected. // Default "/auth/login". LoginPath string // PendingTwoFactorPath is where requests with a session that has // not yet completed the 2FA step are redirected. Default // "/auth/2fa". PendingTwoFactorPath string // AllowedAnonymousPaths are URL prefixes that bypass the auth check. // Used for /auth/* endpoints, static assets, and the health probe. AllowedAnonymousPaths []string } AuthOptions wires the middleware to the auth package. Sessions and Users are interfaces so tests can stub them. type CSRFOptions struct { // TrustedOrigins lists full scheme://host[:port] origins (e.g. // "https://repoman.example.com") from which cross-origin state-changing // requests are accepted. This is only consulted when the browser omits // the Sec-Fetch-Site header and the operator-visible origin differs from // the host repoman sees behind a reverse proxy; every browser since 2023 // sends Sec-Fetch-Site, so same-origin form posts are allowed without any // entry here. TrustedOrigins []string } CSRFOptions controls the cross-origin protection middleware. type RateLimitConfig struct { Burst float64 RefillPerSecond float64 // MaxKeys bounds the key table. The keys are remote addresses, i.e. supplied // by strangers, so an unbounded map is itself a memory-exhaustion vector — // the one this middleware exists to prevent, reintroduced by the fix. MaxKeys int // Event is the security-log event name written when a client is turned away, // so fail2ban can act on sustained throttling as well as on failed logins. Event string } RateLimitConfig describes one endpoint's allowance. Burst is what a client may spend at once — a person who fat-fingers their password three times running must not be locked out of their own instance — and RefillPerSecond is the sustained rate they earn attempts back at. The sustained rate is what an attacker is reduced to, so it is the number that matters for guessing: 0.1/s is six attempts a minute, which turns a six-digit 2FA code from hours of work into decades of it. type RateLimiter struct { // Has unexported fields. } RateLimiter is a per-client token bucket for the credential endpoints. It exists because every other throttle in repoman is per-IDENTITY: the lockout counter is per account, the reset throttle is per email address. Neither bounds what one anonymous client can spend of the server's resources, and neither notices password spraying — one guess against each of a thousand usernames trips no account's counter. This bounds the other axis: attempts per SOURCE, whatever identity they name. It is deliberately hand-rolled rather than pulled from golang.org/x/time/rate. repoman ships as a single static binary with no C toolchain and a short dependency list on purpose (see the CGO-free SQLite and in-process log-rotation decisions); a token bucket is forty lines and this one has to do something the stock limiter does not — bound its own key table, since the keys come from strangers. Zero value is not usable; construct with NewRateLimiter. func NewRateLimiter(cfg RateLimitConfig, secLog *security.Logger) *RateLimiter NewRateLimiter builds a limiter from a config. A non-positive burst or refill is replaced by the login defaults rather than accepted, because a zero burst would reject every request forever and a zero refill would make the first rejection permanent — both are worse outages than the attack. func (rl *RateLimiter) Allow(key string) (bool, time.Duration) Allow reports whether key may spend one attempt now, and if not, how long it must wait for the next one. A caller that is allowed has already been charged. func (rl *RateLimiter) Len() int Len reports how many client buckets are currently held. Exported for tests and for anyone wiring up a metric; it says nothing about any individual client. func (rl *RateLimiter) Middleware(next http.Handler) http.Handler Middleware turns the limiter into an http.Handler wrapper keyed on ClientIP. A rejected request gets 429 with Retry-After and a plain-text body — never the login form — so an attacker's tooling cannot mistake a throttle for a failed attempt, and never a 200, so a naive scraper does not read "wrong password" into it. The rejection is written to the security log too: sustained throttling is itself evidence, and fail2ban should be able to escalate from it.