middleware

package
v0.25.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package middleware holds the mandatory request pipeline.

Order matters and is not a matter of taste: Recover must be the outermost middleware, or a panic raised in any other middleware escapes without a page; Observe must come right after it, because everything below depends on the context it builds.

Index

Constants

View Source
const PasswordConfirmPath = "/auth/password/confirm"

PasswordConfirmPath is where RequireConfirmedPassword sends somebody who has a session but has not typed their password recently.

Fixed for the same reason SignInPath is: it is the address the starter kit registers for that screen, and two parts of a project that disagree about it produce a guard which redirects to a 404.

View Source
const SignInPath = "/auth/login"

SignInPath is where a guard sends somebody who has to sign in.

Fixed, for the reason security.SessionCookieName is fixed: it is the address the framework's auth module registers and the address the starter kit answers at, and a configurable one buys nothing while giving two parts of a project a way to disagree about where the sign-in screen is.

The address somebody is sent to when they are ALREADY signed in is a parameter, because that one genuinely differs -- a blog sends them to the front page, an application to its dashboard.

View Source
const StatusCSRFExpired = 419

StatusCSRFExpired is the status returned when the token is missing, invalid or expired. 419 is the conventional status for it, kept on purpose: HTMX can be told to reload the page on 419, which is the only useful reaction to an expired token.

Variables

This section is empty.

Functions

func CSRFProtect

func CSRFProtect(c *security.CSRF, sessionIDFrom func(*http.Request) string) func(http.Handler) http.Handler

CSRFProtect validates the token on every state-changing method.

THE TRAP THIS SOLVES: with HTMX the token does not arrive in a form field, it arrives in a header. The base template generated by the CLI carries

<body hx-headers='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>

and this middleware accepts both sources. Without that line every project starts out broken, which is why `aru doctor` checks for it.

sessionIDFrom must return the id only for a valid session cookie -- pass SessionStore.IDFromRequest, which verifies the signature first.

func KeyByIP

func KeyByIP(r *http.Request) string

KeyByIP keys on the peer address: the whole address over IPv4, and the /64 it sits in over IPv6.

It reads RemoteAddr and never X-Forwarded-For: a header the client controls is a way to reset someone else's counter. Behind a proxy, have the proxy rewrite RemoteAddr, or key on something the proxy signs. A proxy that does neither gives every request in the world the same key, and then every limit keyed this way is a limit on the whole application -- which for the sign-in throttle means twenty-five wrong passwords a minute across every customer.

Why the IPv6 address is masked

Because otherwise it is not a limit. IPv4 addresses are scarce, so keying on the whole address costs an attacker money; a /64 is the smallest block any end site is given -- a home connection, a VPS, a phone -- and every one of them holds eighteen quintillion addresses that all reach this server. Keyed on the full address, one machine with a routed /64 had an unlimited number of budgets: it could walk a list of accounts forever, and fill the sign-in throttle's table on its own, from a single upstream link.

The /64 and not something wider, because it is the one boundary that is always a single link. A /48 would be one customer at some providers and a whole building at others, and grouping two subscribers under one budget is how a limit locks out somebody who did nothing.

func KeyBySession

func KeyBySession(idFrom func(*http.Request) string) func(*http.Request) string

KeyBySession keys on the session id, falling back to the address for anonymous requests. Pass SessionStore.IDFromRequest as the extractor.

func Observe

func Observe(dev bool, tracingSecret string, recorder *observability.Recorder) func(http.Handler) http.Handler

Observe installs the request id, the request-scoped logger and -- in development, or under an authorized tracing header -- the Collector.

It must come right after Recover: everything below depends on the context it builds.

tracingSecret enables the Collector outside development for requests carrying it in X-Arandu-Trace. Leave it empty to keep production at zero cost.

recorder is the buffer behind /_arandu/debug. Pass kernel.Recorder(); nil records nothing, which is what production does.

func RateLimit

func RateLimit(l Limiter, limit int, window time.Duration, key func(*http.Request) string) func(http.Handler) http.Handler

RateLimit limits by key.

Use KeyByIP for public routes and KeyBySession for authenticated ones: limiting login attempts by IP alone does not stop distributed credential stuffing, because every attempt arrives from a different address.

The refusal goes through httpx.Refuse, like the role guard's 403 and CSRFProtect's 419. This is the third of the three middlewares in this package that turn a request away, and it was the one left on http.Error: htmx swaps no 4xx, so somebody who had hit the limit pressed the button and the screen did not change at all -- the same failure the other two were fixed for, on the one refusal that arrives when a person is already pressing repeatedly. Refusing in two shapes is the inconsistency rather than the fix (RULE 9).

The sentence carries the same number as Retry-After, computed once, because a header and a sentence that disagree about how long to wait are worse than one that says nothing.

func Recover

func Recover(dev bool, opts errorpage.Options) func(http.Handler) http.Handler

Recover captures panics and decides what to render.

In development: the full debug page -- stack, request, queries, dumps. Anywhere else: a bare 500 that leaks nothing, carrying the request id so the operator can correlate it with the structured log.

func RedirectIfAuthenticated added in v0.25.0

func RedirectIfAuthenticated(sessions *security.SessionStore, to string) func(http.Handler) http.Handler

RedirectIfAuthenticated is the guest guard: it keeps somebody who is already signed in off the screens that exist to sign them in.

Without it the sign-in and registration screens render for a person who has a session, which reads to them as having been signed out -- and the next thing they do is sign in again, on top of a session that was never gone.

func RequireAuth added in v0.25.0

func RequireAuth(sessions *security.SessionStore) func(http.Handler) http.Handler

RequireAuth refuses a request that carries no session.

It sends the visitor to SignInPath rather than answering 403, because there is nothing they can do with a 403 and there is something they can do with the sign-in screen.

It remembers where they were going first. This is the only place that knows: by the time a password has been typed, the request that was refused is gone, and a sign-in that always ends at the front page makes somebody who followed a link to one invoice go and find it again. The address goes in a signed cookie rather than in the session, because the session is the thing that does not exist yet at the moment the guard fires -- see SessionStore.RememberIntended, and SessionStore.TakeIntended for the other end of it.

func RequireConfirmedPassword added in v0.25.0

func RequireConfirmedPassword(sessions *security.SessionStore) func(http.Handler) http.Handler

RequireConfirmedPassword admits a request only when the password was typed again on this session less than security.PasswordConfirmationWindow ago.

It is Laravel's `password.confirm` middleware. Mount it on the handful of routes where holding the cookie is not proof enough that the person is there: changing the address the account is recovered through, revealing an API key, closing the account, moving money. The cost of not having it is concrete -- a session cookie lifted from a shared machine is a full account takeover with no step where the attacker has to know anything.

Where the line is against RULE 9

It asks one question: was the password confirmed recently. It never asks may this subject touch this record -- that is the Policy's answer, on every service call the handler behind it makes, exactly as it is behind RequireAuth. A guard that started deciding about records would be a second authorization path, and of the two it is always the guard that gets forgotten: a Policy is written once per entity and reached from everywhere, a guard is mounted per route and the route somebody adds next month has none.

So this is a freshness check on the session, not permission. "Recently confirmed" and "allowed" are different facts, and an application that used this in place of a Policy would let a confirmed subject reach every record in its tenant.

Somebody with no session at all goes to the sign-in screen and not to the confirmation screen: there is no password to confirm yet, and sending them to a form that asks for one on top of no session is a loop.

func RequireRole added in v0.25.0

func RequireRole(sessions *security.SessionStore, roles ...string) func(http.Handler) http.Handler

RequireRole admits a subject carrying any one of the roles.

A visitor with no session is sent to the sign-in screen, exactly as RequireAuth sends them: "you are not signed in" and "you are signed in as somebody without this role" are different situations and only the second one is a refusal. That second one is 403 and not 404 -- the page exists, and pretending otherwise sends somebody looking for a typo.

It is still not an authorization decision about anything. It reads the roles off the session and stops there; the handler behind it goes through the Policy like every other handler.

func SecurityHeaders

func SecurityHeaders(dev bool) func(http.Handler) http.Handler

SecurityHeaders applies the default headers.

The CSP is restrictive on purpose and still works with HTMX, because HTMX operates through attributes rather than inline script. A module that truly needs inline code asks for an explicit nonce -- there is no global 'unsafe-inline' in this framework.

Types

type Limiter

type Limiter interface {
	Allow(key string, limit int, window time.Duration) (remaining int, retryAfter time.Duration, ok bool)
}

Limiter is the rate limit backend. The core ships the in-memory implementation only; the redis adapter provides the distributed one.

type MemoryLimiter

type MemoryLimiter struct {
	// contains filtered or unexported fields
}

MemoryLimiter is a fixed window in process memory. It is right for development and for a single instance. Behind more than one pod a window per pod limits nothing -- use the redis adapter there.

func NewMemoryLimiter

func NewMemoryLimiter() *MemoryLimiter

NewMemoryLimiter returns an empty in-memory limiter.

func (*MemoryLimiter) Allow

func (m *MemoryLimiter) Allow(key string, limit int, window time.Duration) (int, time.Duration, bool)

Allow consumes one unit from the key's window.

func (*MemoryLimiter) Len

func (m *MemoryLimiter) Len() int

Len reports how many buckets are held. It exists so a test can prove the sweep actually bounds memory.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL