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
- func CSRFProtect(c *security.CSRF, sessionIDFrom func(*http.Request) string) func(http.Handler) http.Handler
- func KeyByIP(r *http.Request) string
- func KeyBySession(idFrom func(*http.Request) string) func(*http.Request) string
- func Observe(dev bool, tracingSecret string, recorder *observability.Recorder) func(http.Handler) http.Handler
- func RateLimit(l Limiter, limit int, window time.Duration, key func(*http.Request) string) func(http.Handler) http.Handler
- func Recover(dev bool, opts errorpage.Options) func(http.Handler) http.Handler
- func RedirectIfAuthenticated(sessions *security.SessionStore, to string) func(http.Handler) http.Handler
- func RequireAuth(sessions *security.SessionStore) func(http.Handler) http.Handler
- func RequireConfirmedPassword(sessions *security.SessionStore) func(http.Handler) http.Handler
- func RequireRole(sessions *security.SessionStore, roles ...string) func(http.Handler) http.Handler
- func SecurityHeaders(dev bool) func(http.Handler) http.Handler
- type Limiter
- type MemoryLimiter
Constants ¶
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.
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.
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 ¶
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 ¶
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 ¶
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
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
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
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 ¶
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.