← repoman internals

internal/server/middleware

import "git.griefed.de/griefed/repoman/internal/server/middleware"
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.