Documentation
¶
Overview ¶
Package wui is the reusable browser user interface for looprig: a React SPA built to a static bundle, //go:embed-ed into the consumer's binary, plus the handler and browser guards that serve it next to harness's own pkg/serve routes.
The exported Go surface names no looprig type. github.com/looprig/harness is pinned in go.mod for the test surface only — the contract drift guard and the fixture producers resolve it — and no non-test file in this module may import it. wui imports harness; harness never imports wui.
Index ¶
Constants ¶
const CSRFHeaderName = "X-CSRF-Token"
CSRFHeaderName is the request header the SPA must echo a minted token back in on every state-changing request. This is the wire convention CSRFGuard enforces.
const DefaultCSRFTokenTTL = 4 * time.Hour
DefaultCSRFTokenTTL is the bounded lifetime NewCSRFGuard falls back to when called with ttl <= 0. A few hours comfortably outlives a single working session in an open browser tab — this is a local control-plane UI, not a public site with walk-up users, so there's no pressure to expire aggressively — while still bounding how long a leaked or logged token stays exploitable, and keeping the in-memory token map's contents naturally bounded to "recent" tokens.
Variables ¶
This section is empty.
Functions ¶
func Assets ¶
Assets returns an http.Handler serving the embedded SPA build: a real asset under dist/ if the request path names one, otherwise dist/index.html (the SPA-router fallback).
Every request path is cleaned and confined to the embedded dist/ root before it is handed to embed.FS.Open -- see assetName and assets_test.go for the traversal cases this defends against, and for what was empirically verified about embed.FS's and net/http's own path handling.
func Guard ¶
func Guard(next http.Handler, opts ...GuardOption) http.Handler
Guard wraps next in the Host/Origin guard alone: no CSRF, no routing, no SPA. It is the escape hatch for a consumer assembling its own mux that still wants wui's DNS-rebinding defence. A nil option is ignored rather than dereferenced, so a mis-wired composition root is inert instead of panicking on construction.
func Handler ¶
Handler composes wui's complete browser surface over api (a harness pkg/serve.Handler result): the SPA at /, api under /v1/, the CSRF token route, per-route CSRF on the five state-changing control routes, and the Host/Origin guard over all of it. See this file's package doc for the layering and why CSRF is per-route.
NOTE ON serve.Server: this wrapper is a plain http.Handler, so it does not carry harness's unexported auth-installed proof, and it cannot be made to — Go qualifies an unexported method name by its declaring package, so a wui.authInstalled method is a DIFFERENT identifier from serve.authInstalled and serve's own type assertion would still fail (harness's comment at server.go:116 is wrong on this point; it was verified empirically). The consequence is bounded: serve.Server refuses a bind only when it is non-loopback AND unauthenticated, so a loopback bind — carbon serve's default — is unaffected. A public bind of this handler needs serve.WithInsecurePublicBind().
Types ¶
type CSRFGuard ¶
type CSRFGuard struct {
// contains filtered or unexported fields
}
CSRFGuard mints and verifies per-page-load CSRF tokens for control-plane POSTs (and PUT/PATCH/DELETE, if this package ever adds them). See the package-level doc comment above for the full threat model, wire convention, and storage posture. The zero value is not usable; construct with NewCSRFGuard.
func NewCSRFGuard ¶
NewCSRFGuard builds a CSRFGuard whose minted tokens are valid for ttl. A non-positive ttl falls back to DefaultCSRFTokenTTL.
func (*CSRFGuard) Mint ¶
Mint generates a new token with crypto/rand, records its mint time, and returns it. Call this once per page load (TokenHandler below does exactly that over HTTP); the caller is responsible for delivering the result to the SPA (see the package doc comment). This is the one place lazy full-map maintenance happens: expired entries are pruned (evictExpiredLocked) and, if the live count still exceeds maxCSRFTokens, the oldest survivors are evicted too (evictOldestLocked) — verify (called on every control POST, far more often than Mint) does neither and stays a plain O(1) lookup.
func (*CSRFGuard) TokenHandler ¶
func (g *CSRFGuard) TokenHandler() http.HandlerFunc
TokenHandler builds the GET /v1/csrf-token handler that Handler (handler.go) registers. It mints a token via Mint — kept as the single source of mint logic so the handler and Verify/verify can never drift apart — and writes it as {"csrf_token": "..."}. The response is explicitly non-cacheable (Cache-Control: no-store): a CSRF token is a security credential, and any cached copy served back to a later, different page load would be stale and misleading at best. X-Content-Type-Options: nosniff pins the declared JSON content type against MIME sniffing.
This route is deliberately GET (never state-changing), so CSRFGuard.Wrap never demands a token to reach it — a client with no token yet must be able to fetch its first one.
func (*CSRFGuard) Wrap ¶
Wrap returns next wrapped so that GET, HEAD, and any other non-state-changing method pass straight through untouched, while POST, PUT, PATCH, and DELETE require a valid, unexpired token in the CSRFHeaderName header — missing, unknown, or expired all answer 403 with codeCSRFInvalid (see errors.go), matching HostOriginGuard's fail-secure, reject-fast-before-next convention (see guard.go). A rejected request never reaches next. codeCSRFInvalid is marked retryable: true — a client that clears its cached token, mints a fresh one from TokenHandler, and retries the identical request once is following the intended recovery path, not fighting the guard.
type GuardOption ¶
type GuardOption func(*config)
GuardOption configures the Host/Origin guard. It is a distinct type from Option because Guard takes only these — a caller wrapping an arbitrary handler has no CSRF store to configure.
func WithAllowedHosts ¶
func WithAllowedHosts(hosts ...string) GuardOption
WithAllowedHosts widens the Host/Origin allowlist by the named hosts. It is ADDITIVE, never a replacement: the three loopback forms (127.0.0.1, localhost, [::1]) are always allowed. It exists for the "public bind is opt-in" case, where a composition root deliberately serves a specific additional hostname.
type HostOriginGuard ¶
type HostOriginGuard struct {
// contains filtered or unexported fields
}
HostOriginGuard is standard net/http middleware — see NewHostOriginGuard — that rejects any request whose Host header, or whose Origin header when present, does not name one of its allowed hosts.
func NewHostOriginGuard ¶
func NewHostOriginGuard(extraAllowedHosts ...string) *HostOriginGuard
NewHostOriginGuard builds a HostOriginGuard accepting the three loopback host forms — 127.0.0.1, localhost, [::1] — on any port (or no port at all), plus any hosts listed in extraAllowedHosts. extraAllowedHosts is additive, never a replacement: it exists for the "public bind is opt-in" case, where a composition root deliberately widens the allowlist to a specific additional hostname. Calling it with no arguments — the common case — is loopback-only.
func (*HostOriginGuard) Wrap ¶
func (g *HostOriginGuard) Wrap(next http.Handler) http.Handler
Wrap returns next wrapped so the guard runs FIRST — reject-fast at the edge, before any auth or business logic in next ever sees the request. A rejected request never reaches next at all: fail secure means an unparseable Host or Origin is a rejection, not a pass-through, and every rejection answers 403 (the route exists; the request's origin doesn't).
type Option ¶
type Option func(*config)
Option configures Handler.
func WithCSRFTokenTTL ¶
WithCSRFTokenTTL sets the lifetime of minted CSRF tokens. A non-positive value falls back to DefaultCSRFTokenTTL.
func WithGuardOptions ¶
func WithGuardOptions(opts ...GuardOption) Option
WithGuardOptions applies guard options to a Handler. It exists so the one WithAllowedHosts spelling serves both entry points.