Security defaults

core/middleware provides the defensive HTTP primitives the framework composes by default. Most apps should accept the defaults and override specific knobs rather than rebuild the chain.

The default stack

framework.NewApp installs this middleware chain on app.Router unless you pass WithoutDefaultMiddleware(). (app.Use(...) appends your middleware to the chain. It does not replace or disable the defaults; use WithoutDefaultMiddleware() when you want to build the chain yourself.)

5 lines
middleware.Recovery()
middleware.RequestID()
middleware.Compress()          // only with AppConfig.Compression
middleware.SecurityHeaders(middleware.SecurityHeadersConfig{})
middleware.Timeout(30 * time.Second)

(WithI18n inserts its middleware between RequestID and SecurityHeaders when configured. WithIdempotency is not in this chain: it mounts innermost with router.UseInner, after the app's own auth and tenant middleware.)

Recovery is outermost so a panic anywhere below it produces a clean 500. That 500 drops the representation headers the handler had set (Content-Type, Content-Encoding, Content-Length, ETag, Last-Modified, Content-Range, Accept-Ranges, Content-Disposition) and is sent Cache-Control: no-store, so a panicking asset handler's error page is never cached as the asset; cookies and the other headers stay. A panic after the response started (a status sent, a flush, body bytes, a hijack) cannot become a 500: Recovery logs it and cuts the connection, so the client sees a truncated response rather than error text after a 200 body. A panic in a handler Timeout runs on its own goroutine is logged with that goroutine's stack, so the log names the code that panicked. RequestID runs next so every later log line carries the trace ID. Compress, when AppConfig.Compression is set, gzips text responses for clients that send Accept-Encoding: gzip; it sits outside everything that writes the body, so it sees the final bytes. Timeout is innermost: a 30s deadline that cancels the request context if the handler hangs.

Compression

The default chain does not compress: most deployments put a reverse proxy or CDN in front of the app, and it compresses there. Set AppConfig.Compression when the app faces clients directly:

4 lines
app := framework.NewApp(framework.WithConfig(framework.AppConfig{
    Compression: true,
}))
_ = app

Compress encodes HTML, CSS, JavaScript, JSON, XML, SVG and plain text of 1 KB or more for clients whose Accept-Encoding admits gzip (a named gzip;q=0 refuses it even beside a * wildcard), and adds Vary: Accept-Encoding to every response it could have compressed. It leaves alone HEAD requests, Range requests, text/event-stream, bodies that already carry a Content-Encoding, 204/206/304 answers and hijacked connections. An ETag gains a -gzip suffix on the compressed variant, and a conditional request carrying that suffix still revalidates to 304; an If-Match carrying it is mapped back too, so an optimistic-concurrency write that echoes the tag the client was handed is not refused with 412. A compressed response drops Accept-Ranges, since a range request is served uncompressed. Responses marked immutable (hashed static assets, runtime.js, manifest.js) are compressed at the best level and cached in memory by content hash from the second time the same bytes are served. A one-off body is not kept, so a client that mints immutable URLs has to request each one twice to displace a cached entry, and the cache's byte bound holds either way. A body under a strong ETag is held until the handler returns and compressed in one pass, so the tag names one encoding; if its handler flushes, it streams and the tag turns weak. Everything else streams at the default level. A handler that panics after the response started cannot get a 500: the connection is cut, so the client sees a truncated response. The encoding is gzip only: the standard library has no Brotli. An empty file copy or a source error before its first byte does not commit response headers, so the handler can still choose an error status. Source errors returned alongside the first bytes are preserved, including at the content-sniffing boundary. Identity responses preserve the underlying writer's direct file-copy path after reading the first byte and any required content sniffing. Compressed responses still use the same streaming or held-body encoding rules for file copies.

A compressed page that reflects attacker input next to a secret leaks the secret through response sizes (BREACH), whether the app or a proxy in front of it compresses. The framework's own secret in pages is the CSRF token, and it never appears raw; see masked tokens below. A secret the app renders itself (an API key, an invite link) next to reflected input is the app's to mask or move out of the page.

Router middleware runs only for requests the router answers. A gate that must hold for every byte the listener serves, the interactive setup wizard and the worker and agent roles' reduced muxes included, goes in app.WrapServer(func(http.Handler) http.Handler) instead. The first wrapper registered is the outermost; call it before Start (a battery's Init is in time), since a call once the server has been built, even after Shutdown, panics. battery/service installs its loopback gate this way.

Access logging is deliberately not in the chain: battery/log owns structured access logging when registered, and an app that just wants a basic line can add middleware.LoggingFn(app.Logger) itself; running both would double-log every request.

SecurityHeaders

6 lines
middleware.SecurityHeaders(middleware.SecurityHeadersConfig{
    ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://cdn.example.com",
    ReferrerPolicy:        "strict-origin-when-cross-origin",
    FrameOptions:          "SAMEORIGIN",
    PermissionsPolicy:     "geolocation=(self)",
})
HeaderDefault
Content-Security-Policydefault-src 'self'; img-src 'self' data:; object-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'
X-Content-Type-Optionsnosniff (always, not configurable)
Referrer-Policyno-referrer
X-Frame-OptionsDENY
Permissions-Policygeolocation=(), microphone=(), camera=()
Strict-Transport-Securitymax-age=31536000 (1 year), HTTPS responses only

object-src and form-action are named explicitly because default-src does not cover them: CSP never let form-action fall back, and object-src's fallback was removed in CSP3. A policy of just default-src 'self' therefore leaves an injected <form> free to post the page's data anywhere, and <object>/<embed> free to execute, so a custom ContentSecurityPolicy should keep both directives.

Static exports

A static export (the app binary's --export <dir> flag) writes the policy into every page as an in-document <meta http-equiv="Content-Security-Policy"> and emits a _headers file for hosts that read one (Netlify, Cloudflare Pages). Response headers are a server's job, and a static export has no server; without this the export would be the one deployment target shipping the runtime with no CSP at all.

The meta does not carry the clickjacking guard. Per CSP Level 3 §3.1, a <meta>-delivered policy ignores frame-ancestors (alongside report-uri and sandbox), so the meta enforces the fetch directives but not frame-ancestors 'none'. That directive reaches the browser only through the _headers file, and the export emits no X-Frame-Options header either, so a host that ignores _headers (S3, GitHub Pages) serves the pages with no clickjacking guard at all. On those hosts, configure the CDN to send Content-Security-Policy (or X-Frame-Options: DENY) as a real response header.

HSTS is on by default. Strict-Transport-Security is emitted with a one-year max-age whenever the request is HTTPS: direct TLS, or a TLS-terminating proxy that sets X-Forwarded-Proto: https (the app sees plain HTTP there). Plain-HTTP local dev never receives it. Set HSTSMaxAge: -1 to disable, a positive value to change the age, or HSTSIncludeSub / HSTSPreload to extend it. Secure: true forces HSTS even on plain HTTP. Its default, false, keeps TLS and proxy auto-detection; it is not an off switch.

The CSP default works with the built-in UI runtime because its CSS and scripts are served from /__gofastr/*, with one exception the host allows by hash. A first-load page whose head resources fit 16 KiB compressed carries the color-scheme bootstrap, the component bundle (or the part of it the page's markup can match) and app.css inline, so it paints from the first response, and the UI host adds each block's 'sha256-…' hash to the response's Content-Security-Policy (and any Report-Only policy). The hashes go into the governing style-src-elem/style-src and script-src-elem/script-src lists, or into a new style-src / script-src copied from default-src. A policy that refuses the kind outright ('none') keeps the page on links and is left untouched; a list that already allows 'unsafe-inline' with no nonce or hash is left alone.

The host can only patch a policy that is on the response when the page renders: the default chain's, or one your own middleware set. If the policy is written somewhere else (a reverse proxy, a CDN rule, a <meta> tag in a custom layout), the hashes never reach it and the browser would refuse the inlined blocks. Pass uihost.WithNoInlineFirstPaint() there to keep every head resource an external link. Static exports always link.

If you embed third-party scripts, fonts, or frames you must override ContentSecurityPolicy explicitly; do not relax it with 'unsafe-inline' globally.

Configuring the default chain's headers

The example above constructs the middleware by hand. The default chain installed by NewApp is configurable through AppConfig.SecurityHeaders (or the framework.WithSecurityHeaders(cfg) option), so you can relax a single directive, e.g. allow style-src 'unsafe-inline' for a third-party CSS dependency, without shadowing the whole chain with your own SecurityHeaders middleware:

3 lines
app := framework.NewApp(framework.WithSecurityHeaders(middleware.SecurityHeadersConfig{
    ContentSecurityPolicy: "default-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:",
}))

Unset fields keep their built-in defaults (the strict CSP, Referrer-Policy: no-referrer, X-Frame-Options: DENY, …), so a partial override never silently drops a defensive header. The zero value reproduces the original strict defaults exactly.

CORS

5 lines
middleware.CORS(middleware.CORSConfig{
    AllowedOrigins: []string{"https://app.example.com"},
    AllowedMethods: []string{http.MethodGet, http.MethodPost},
    AllowedHeaders: []string{"Authorization", "Content-Type"},
})

CORS is not in the default chain. Add it explicitly if your API serves browser clients on another origin.

A browser client that sends headers you do not list one by one (X-Request-Tag, tracing headers) fails its preflight. Set AllowAnyHeader: true to answer an allowed origin's preflight with the headers it asked for (Access-Control-Request-Headers), and AllowAnyMethod: true to do the same for the method. Only well-formed names are echoed, at most 64 of them. While either option is on, every answer to an allowed origin adds Vary: Access-Control-Request-Headers or Vary: Access-Control-Request-Method. A rejected origin still gets no Access-Control-Allow-* header and a 403 preflight. An OPTIONS request with no Origin or no Access-Control-Request-Method is not a preflight and reaches your router.

CSRF

7 lines
middleware.CSRF(middleware.CSRFConfig{
    CookieName:   "fui_csrf",
    HeaderName:   "X-CSRF-Token",
    Skip:         middleware.SkipBearerAuth(),
    SecretKey:    loadCSRFKeyFromEnv(), // 32+ random bytes
    CookieSecure: true,                 // production HTTPS
})

Issues a cookie on safe requests; requires the matching header on mutating requests (POST, PUT, PATCH, DELETE). SkipBearerAuth() is the shipped helper that bypasses CSRF on requests with Authorization: Bearer … or X-API-Key, appropriate for pure API deployments where the browser is not involved. Other Authorization schemes (Basic, Negotiate) are not skipped: the browser attaches them by itself. A verified mutating request carries its token on the context (TokenFromContext), so a re-rendered form can embed it.

Always set SecretKey explicitly in production. The middleware will autogenerate one if omitted, but that key rotates every process restart, and the auditable signing seam moves into the binary instead of into your secret store. Source it from your config / secret manager the same way you'd source SessionSecret.

On a secure request (CookieSecure, TLS, or X-Forwarded-Proto: https) the cookie is named __Host- plus CookieName, so the example above sets __Host-fui_csrf. The browser refuses a __Host- cookie from a sibling subdomain. That matters because the token is not tied to a session: a sibling such as evil.example.com could otherwise fetch a valid token, plant it as a Domain=example.com cookie, and post it with the victim's credentials. NoHostPrefix: true keeps the name as given; CSRF refuses a CookiePath other than / without it, since a __Host- cookie must have Path=/.

On the next safe-method request (GET / HEAD / OPTIONS) the middleware self-heals stale or tampered cookies: it verifies any incoming cookie against SecretKey + AdditionalKeys and silently re-mints one if the signature doesn't validate. This means a process restart (which rotates an auto-generated key) or a key rotation that drops the previous secret no longer leaves browsers stranded with a cookie that's guaranteed to 403 the next POST. To carry tokens across a planned rotation without bouncing in-flight forms, list the previous secret(s) in AdditionalKeys; drain once the old tokens have expired.

Client-side navigation refreshes the live CSRF meta token only when a network response supplies one. Page-owned metadata cleanup and cached back/forward navigation retain that live token, including after signing-key rotation.

Masked tokens

Render middleware.MaskCSRFToken(token) into a page, never the raw cookie value. The masked form is m1. plus a fresh one-time pad and the token XORed with it, so its bytes differ on every render and a compressed response gives a BREACH-style attacker nothing to converge on. CSRF unmasks a submitted value before comparing it, and still accepts the raw token from scripts that read the cookie. ui.Form's hidden field and the auth battery's CSRFToken / CSRFTokenFromCtx already return masked tokens, so a <meta name="csrf-token"> rendered from them (the tag the runtime forwards as X-CSRF-Token) is masked too. Client-side navigation refreshes this meta token when the fetched document or partial head carrier supplies it, including after a signing-key rotation. An omitted token does not clear the current one. middleware.TokenFromContext stays raw: never render it.

Re-dispatched writes

An entity's MCP write tools (and the A2A skills built on them) send the call through the router again as an internal request carrying the caller's cookies, so the same middleware decides it. With CSRF on that route the internal request needs the token as well. CSRF records the token an unsafe request passed with, and middleware.VerifiedCSRFSubmission(ctx) returns it with the header name it was checked under. The entity tools present it on the internal request, whose own check compares it with the cookie again, so nothing is exempted. A request the check skipped (a bearer token, an embed endpoint) has no token to present. The token is the raw cookie value: never render it.

Cross-site form guard (Fetch Metadata)

3 lines
if handler.IsCrossSiteRequest(r) {
    http.Error(w, "cross-site form", http.StatusForbidden)
}

handler.IsCrossSiteRequest is the one shared predicate for refusing a cross-site form post: Sec-Fetch-Site: cross-site decides when the browser sends Fetch Metadata, and an Origin-header host compare against the request's own host is the fallback for clients (older browsers, tests, native apps) that send neither. Sec-Fetch-Site: same-site also goes to the host compare, since a sibling subdomain is same-site, and there an absent or null Origin is refused: a page on the same origin says same-origin, so only a sibling sending Referrer-Policy: no-referrer arrives that way. IsForgeableRequest answers whether the request's content type is one a plain HTML form can produce, for guards that only need to worry form-shaped posts. IsCrossSiteRequestStrict is for surfaces only ever called by fetch(): there an opaque Origin: null with no Fetch Metadata to vouch for it is the sandboxed-iframe shape, not a legitimate caller, and is refused. The auth battery's cross-site guards, setup tokens, the admin console, and kiln all route through these helpers; writing a fourth copy of the check is what GOFASTR1411 fires on.

Routes you mount yourself

Entity endpoints and CRUD routes refuse a forged cross-site write before their handler runs. A route you mount with app.Router().HandleFunc gets no such check, and the CSRF middleware needs a token in every form. Wrap a plain server-rendered form route in handler.RefuseCrossSite:

1 lines
mux.Handle("POST /profile", handler.RefuseCrossSite(saveProfile))

It answers 403 to any method but GET, HEAD and OPTIONS (a CORS preflight passes) that has a content type a plain HTML form can send (IsForgeableRequest) and comes from another site (IsCrossSiteRequest); handler.IsForgedCrossSiteWrite is the same test as a predicate. Your own pages, non-browser clients and preflighted requests (application/json) pass. Leave unwrapped a route that must take a cross-site POST, such as an OAuth form_post callback.

Secret rotation

Two app secrets can be rotated without a mass logout: the uihost session signing key (GOFASTR_SECRET) and the auth battery's JWT signing key (AuthConfig.JWTSecret). Both follow the same shape as the CSRF AdditionalKeys idiom above: sign with the current secret, verify against the current OR any listed previous secret, then drop the previous secret once the drain window (one session/token TTL) has elapsed.

GOFASTR_SECRET (uihost session tokens)

Set the new secret as the current and move the old one into the previous list. New session tokens are signed with the current secret; tokens signed by a previous secret still verify until the window closes.

4 lines
app := framework.NewApp(
    framework.WithSecretRotation(newSecret, oldSecret),
    // ...rest unchanged
)

Equivalent zero-code path via the environment (comma-separated; each entry must independently meet the 32-char floor):

2 lines
GOFASTR_SECRET=new-secret-...
GOFASTR_SECRET_PREVIOUS=old-secret-...

An explicit WithSecretRotation option wins over GOFASTR_SECRET_PREVIOUS. WithSecret(secret), the no-rotation shorthand, stays unchanged.

AuthConfig.JWTSecret (auth battery JWTs)

5 lines
mgr := auth.New(auth.AuthConfig{
    JWTSecret:          newSecret,            // signs new tokens
    JWTPreviousSecrets: []string{oldSecret},  // verify-only during the drain
    // ...rest unchanged
})

See auth for the full procedure. Production mode still requires a non-empty JWTSecret; a previous-only configuration is rejected at Init.

Rate limiting

6 lines
middleware.RateLimit(middleware.RateLimitConfig{
    Capacity:    100,         // peak burst
    RefillEvery: time.Minute, // +RefillBy tokens per interval
    RefillBy:    100,
    MaxKeys:     100_000,     // resident-bucket cap (default)
})

Token-bucket per key. KeyFunc defaults to RemoteAddr (X-Forwarded-For is ignored unless TrustProxyHeaders + TrustedProxies are set, and is then read from the right: the key is the first hop not in TrustedProxies, so client-supplied entries an appending proxy keeps on the left never pick the bucket). Tune Capacity/RefillEvery/RefillBy per route by composing two RateLimit middlewares in different middleware.Chain calls.

The in-memory bucket store caps its resident entry count at MaxKeys (default 100_000, the framework/ratelimit convention): an IPv6 peer rotates source addresses without limit, and the reap only drops buckets that have refilled to capacity, which a flood's fresh keys have not. Past the cap the store sheds down to a low-water mark, most tokens left first (counting the refill ticks that have landed), then nearest to full: a flood's buckets have each spent one token, so they go before a key that spent more of its budget, also under the default RefillBy (equal to Capacity), where one tick refills either. A bucket that still owes tokens is never reaped for being quiet, only shed past the cap. At Capacity: 1 a flood's single token is its whole budget too, so a flood of MaxKeys new keys can shed an exhausted key (the one nearest refill goes first) and hand it a fresh token early. A limit that must hold through a flood of that size raises MaxKeys, or uses framework/ratelimit with a shared Store (see gofastr docs rate-limit).

On every response that passes through it (both allowed and 429) the middleware also emits the IETF-draft budget headers so well-behaved API clients can self-pace: RateLimit-Limit (the configured Capacity), RateLimit-Remaining (tokens left after this request), and RateLimit-Reset (whole seconds, rounded up, until the bucket is back at full capacity). Set OmitBudgetHeaders: true to suppress them when the per-response header cost matters at scale or an upstream cache would shard by remaining budget; Retry-After on the 429 path is unaffected. The auth battery's own limiter (battery/auth) intentionally exposes only Retry-After and never the budget headers; a live remaining-attempt count on login / password-reset endpoints would hand an attacker exact brute-force pacing information. That limiter is the general-purpose sliding-window limiter now in framework/ratelimit, the same package to reach for when you want "at most N per period, then lock out" on your own routes. See rate-limit.md for the quickstart and the token-bucket-vs- sliding-window choice.

Recovery screens and cache policy

When middleware refuses a guarded screen route, it answers with uihost.UIHost.RenderScreen (see ui-wiring → "Guards on dynamic screens"). Its full-page arm always answers Cache-Control: no-store with Vary: Cookie and verifies or mints the anonymous stream session like every page load; its client-side-navigation arm defaults to Cache-Control: private, no-store and mints nothing. Three rules keep the failure surfaces safe:

  • A recovery page is per-user. It was rendered for one caller's auth state; a shared cache replaying it to a second visitor leaks that state and can pin a stale grant. Don't override the default CacheControl without a reason you can write down.
  • Don't reveal existence. Answer an expired session and a missing session with the same screen and status, so an unauthorized caller can't probe whether a given id is live.
  • Keep statuses truthful. 401/403 for an authentication failure on a route that exists, 410 (or a 404 via the screen's ScreenStatusCode) for a route that resolved but whose resource is gone, and a plain 404 — never a recovery screen — for a path nothing matches.

The partial-navigation arm matters as much as the full page: the runtime keeps the response out of the screen cache on a non-2xx, but any proxy between client and server obeys the headers, which is what no-store is for.

The same posture holds wherever a response is per-caller: RFC 9111's storage restriction covers requests with an Authorization header, NOT cookie-authenticated ones — this framework's default session auth — so every authenticated 2xx body suppresses storage itself. CRUD responses, the auth battery's token and account listings, A2A POST responses, uihost pages (no-store plus Vary: Cookie), kiln's JSON GETs, and the harness REST surface all stamp Cache-Control: no-store unless the handler set its own Cache-Control. The nostore analyzer holds the family: a handler that resolves a principal and writes a 2xx body with no Cache-Control on the path fires it.

OpenAPI coverage for auth endpoints

Auth endpoints registered by AuthManager.RegisterRoutes (login, register, logout, /auth/me, /auth/password, /auth/2fa/, /auth/oauth/, magic-link, verify-email, forgot-password, reset-password, /auth/accounts, /auth/unlink/{provider}) are not currently part of the auto-generated OpenAPI spec.

framework/openapi.EntityOpenAPI walks the entity registry to emit schemas for entity CRUD routes; the app passes its route predicate so the served spec never documents CRUD paths registration did not mount (no DB, or Exposure.CRUD=false — declared custom endpoints stay documented either way). EntityOpenAPIWithBulk takes a second predicate and also documents the _bulk, _export.csv and _pick routes App.EntityUI mounts; the served spec passes it. EntityOpenAPIFor takes all of that as an openapi.Options struct, plus JSONCase: the served spec passes the app's AppConfig.JSONCase, so a snake_case app documents each field under its column name, the key its routes send (author_id, not authorId). A spec built by EntityOpenAPI or EntityOpenAPIWithBulk names fields in camelCase. Options also takes ReadOnlyMounted, which keeps a read-only mount (App.View) to its List, Get and _events operations. App.OpenAPISpec(title) returns the served document itself, built with all of the app's settings (route predicates, read-only mounts, APIPrefix, JSONCase): a host that mounts the entity routes without App.Start, as kiln's live studio does, serves that one. Plugin-registered HTTP handlers go through router.Post / router.Get / … directly and don't carry schema metadata that the spec generator can consume. There is no plugin → OpenAPI extension hook today.

Until that hook lands, the auth routes are documented through this file, the plugin source comments, and integration tests. If your deployment needs an OpenAPI document that includes the auth routes, hand-write them into a sibling spec and merge with the generated one in the gateway / docs pipeline.

The full inventory

core/middleware exports:

  • RequestID(): generates or echoes X-Request-ID.
  • Recovery(): turns panics into 500 with structured log line (a no-store 500 without the handler's representation headers), or cuts the connection when the response had already started.
  • Logging() / LoggingFn(getLogger) / LoggingWithWriter(io.Writer): structured request log. LoggingFn reads the logger per-request so plugins can swap it after the chain is wired.
  • SampledLogging(sampleN, slowThreshold): logs 1-in-N requests but always logs errors (status ≥ 400) and slow ones (duration > slowThreshold). Preferred for production paths where the unsampled Logging() cost dominates the middleware chain.
  • DiscardLogging(): request-timing wrapper that emits no log lines; for high-throughput paths where structured logging is handled by an upstream proxy or APM agent.
  • SecurityHeaders(SecurityHeadersConfig): defensive headers above.
  • CORS(CORSConfig): cross-origin headers + preflight. A preflight is OPTIONS carrying both Origin and Access-Control-Request-Method; it answers 204 for an allowed origin and 403 otherwise. Any other OPTIONS reaches your handler. Under an origin allow-list every response carries Vary: Origin, matched or not.
  • CSRF(CSRFConfig): double-submit cookie pattern.
  • MaskCSRFToken(token): per-render masked form of a CSRF token for page bodies.
  • VerifiedCSRFSubmission(ctx): the header name and raw token an unsafe request passed CSRF with, for a server-side re-dispatch.
  • Compress(): gzip for text responses (see Compression).
  • RateLimit(RateLimitConfig): token-bucket per key.
  • Timeout(d): per-request deadline; cancels context on expiry. A hijack that fails leaves the response buffered and the deadline armed.
  • NewMetrics() + MetricsMiddleware + MetricsHandler: RED metrics.
  • Tracing(): OpenTelemetry span around each request.

Each has a *_test.go you can read for the exact behaviour.

Availability notes

  • SQLite serialises writes. Concurrent write load can climb to 100ms+ p99 latencies and starve out non-write traffic, a soft DoS vector for any endpoint that writes. Set MaxOpenConns(1) on the *sql.DB, keep writes off the request path where possible (queue + background worker), or run Postgres. Full discussion in migrations §Concurrency model.

Local control planes and DNS rebinding

Anything that binds loopback and accepts privileged requests needs a Host check, not just an Origin check. Origin alone cannot stop DNS rebinding: the attacker points their own domain at 127.0.0.1, so the browser treats the request as same-origin and Origin matches Host. Comparing Host against the authority the server expects is what refuses it, because a rebound request still carries the attacker's name.

This applies to four surfaces the framework ships:

  • /mcp: core/mcp.Server refuses a browser Origin that is not same-origin with the request. Pin the authority with SetAllowedHosts (or SetRequireLoopbackHost(true)) when the transport is local; allow tunnels with SetAllowedOrigins. gofastr dev pins to loopback automatically, because dev mode implies the mutating control tools plus every entity's write tools with no auth in front of them.
  • gofastr harness: the sidecar pins Host to the authority it bound. Its chat page carries the bearer token in a meta tag, so an unpinned Host would let a rebound page read the token and then drive the agent.
  • kiln serve: pins Host to loopback whenever --addr bound a loopback address. kiln also refuses request-borne agent commands unless started with --allow-custom-agent: that form lets the request body choose the argv of a spawned process.
  • battery/service: the installed daemon and its dev run pin Host to their own loopback address plus Config.AllowHosts, refuse cross-site unsafe methods, and require a session cookie minted from a single-use token the control channel hands out. There is no switch that turns the Host check off. See service.md.

A deliberate non-loopback bind (--addr 0.0.0.0:…) skips the pin, since the framework cannot know the intended public name. That is the point at which the "unauthenticated" warning in the startup banner is the contract.

Widget signal exposure

A widget's /state endpoint is unauthenticated by default, and SignalSource.Read takes no request context, so a signal value is process-global, identical for every caller, and cannot be scoped per user. Treat every signal as world-readable: counts, statuses, and other non-sensitive display data.

Widgets whose signals should not be public set a session gate on the Definition. There are two levels. Definition.RequireSession gates /state, /chrome, and the RPC routes behind any valid browser session — under framework/uihost every page render auto-mints one, so this level scopes a widget per-session (anti-recon), it does not ask for a login. Definition.RequireAuthenticated is the authentication gate: the request must resolve to a signed-in user (under uihost, the user battery/auth's SessionMiddleware/RequireAuth loaded onto the request context), which the anonymous session a first page load mints never satisfies. Both gates fail closed: if the host installed no predicate for the level, a widget that asked for one serves nothing rather than serving everyone, and SSR-inlined widget chrome applies the same verdict (widget.GateSatisfied) so a gated widget's chrome is not baked into a page its endpoints would refuse.

Owner isolation and CrossOwnerRead

Entities with OwnerField scope every read/write to the requesting user's rows: the framework refuses anonymous requests (401) and injects WHERE <owner_field> = <ctx user id> into every query so a user can never see or mutate another user's data. CrossOwnerRead optionally widens this for reads only: when the request context holds the named RBAC permission, List/Get/Count span all owners. Writes (Create/Update/Delete) stay owner-scoped regardless, and multi-tenant isolation is preserved: a granted context in tenant A never sees tenant B rows. The widening is fail-closed: no access policy in context ⇒ no widening. See entity-declarations → "CrossOwnerRead".

Masked fields and the query surface

Redacting a field in an AfterGet / AfterList hook changes what the caller reads. It does not change what the database filtered and sorted on, so the stored value stays reachable through the query surface:

2 lines
GET /cards?number_like=4111    → 1 row    every response still
GET /cards?number_like=4112    → 0 rows   reads "****1111"

Repeat that a character at a time and the masked value comes back in full. ?sort=number leaks relative ordering the same way.

Mark such a column NoQuery (no_query: true in a declaration). The field stays in responses, and every wire query surface refuses it: flat filters, ?sort= (including alongside ?cursor=, where the sort is ignored but still validated), ?where= predicate trees, nested ?rel.field= filters, scoped include filters, and the DSL all return a 400 naming the field. ?q= search is refused earlier still: listing a NoQuery column in SearchFields panics at Define, so the app fails to start rather than serving a searchable mask, as does naming one as a cursor field.

The in-process Go API is deliberately not gated. TypedQuery.Where accepts a caller-built condition on a NoQuery column and returns the stored value, because read-modify-write, seed lookups, and aggregates all need the real row; the server cannot tell those apart from a rendered list. Where rows reach an end user, pass crud.WithReadHooks(ctx) so the read hooks the HTTP surface runs apply: the BeforeList/BeforeGet scopes and the AfterList/AfterGet masks (see hooks-and-transactions.md).

A nested ?rel.field= filter needs the target entity's schema to run that check, so an unresolvable target refuses the filter rather than skipping the check. A relation may legitimately point at a table no entity registers; the auth battery self-migrates auth_users. Trusting the column name there meant any column of that table could be predicated on. ?include= has always refused the same shape.

A nested filter on a target that declares Scope.OwnerField or Scope.MultiTenant carries the caller's owner and tenant predicates into the EXISTS subquery. The clause counts rows without selecting them, so without those predicates it counts every row in the target table and the parent's row count confirms values in other owners' or tenants' rows one guess at a time. The predicates come from the same builder the include and eager paths use, so the three surfaces answer alike. A caller holding a cross-owner or cross-tenant grant gets no predicate on that axis, since they can already list the target wholesale; the axes are independent. See access control.

The two gates part company in process. The posture gate, which asks whether the caller may read the target entity at all, is HTTP-only: it is the baseline session requirement, and in-process code runs with no session by construction, so asking it there refuses every nested filter a background job makes and protects nothing. ApplyIncludes draws the line in the same place.

The scope predicates are not HTTP-only. Owner, tenant, and read scope narrow a nested filter on every surface, in-process included, because they are what stops the EXISTS clause counting rows the caller may not see. A host handler that forwards a caller-influenced relation into ListAll or CountAll gets the same narrowing the HTTP path applies.

Server-side code that genuinely means "read across every owner" says so with owner.AllowCrossOwner or tenant.AllowCrossTenant. Both are explicit at the call site, both are unreachable from an HTTP route, and both are already how every other in-process read widens its scope.

Hidden and NoQuery are enforced when a nested-filter spec is RESOLVED, on both surfaces, because those describe the data rather than the caller: a masked column stays masked no matter who asks. That is specific to nested filters — TypedQuery.Where remains deliberately ungated for NoQuery, as described above.

Foreign keys on writes are scope-checked

Read paths carry the target entity's posture: an ?include= or a nested filter is refused when the caller may not read the related entity, or the registered join entity a many-to-many relation runs through, and the target's BeforeList scope narrows the rows it returns or matches. The join entity's own BeforeList scope narrows the link rows the same way. Write paths now resolve the same predicate. A create or update that sets a BelongsTo relation column such as order_id or author_id is answered with 404 when the target row exists but the caller cannot read it under the target's own owner, tenant, and read scopes — the exact status reading that foreign row gives, so existence is not leaked. A target whose own BeforeList/BeforeGet hooks scope reads is checked under their clauses too, as ?include= over the relation applies them: a reference onto a row they hide, or onto one that does not exist, is

  1. The

existence check underneath it (FOREIGN KEY on both dialects; AutoMigrate emits the clause and the sqlite3 driver name defaults _pragma=foreign_keys(1)) still rejects a fabricated id.

The gate mirrors the read side's exemptions exactly: a caller holding owner.AllowCrossOwner, the target's CrossOwnerRead permission, tenant.AllowCrossTenant, or a crud.WithServerWrites context (a host-driven write: jobs, imports, admin tooling) writes the reference unchanged. A NULL or absent FK, an unscoped target, or a target the registry does not know is skipped. So is an update that sends the reference the row already holds, every column of it, read under the same tenant and owner scopes as the UPDATE: an edit form re-sends a target the caller may no longer read (an archived customer), and keeping it crosses no boundary. A reference that changes in any column, or one that leaves a stored NULL, is checked. Creates, upserts and TypedQuery.UpdateAll read no stored row and check every reference they name. The same reasoning a BeforeCreate/BeforeUpdate hook was told to do in earlier versions (hooks and transactions) is framework behaviour for BelongsTo now; a hook is only needed for has-many retargeting or business rules beyond scope.

App spec screens are checked at generate time, because several of them reach the database without passing through the HTTP filter parser: a stat_card source.filter or summed source.field, and a chart's group_by. (The screen-level search:/filters: keys are gone; the entity's search_fields: and display: facets: are checked at entity registration instead.) The chart is the sharpest of these: group_by renders each distinct stored value as a bar or slice LABEL, so a masked column would print in full on a page whose table shows the mask.

Hidden already implies all of this: it removes the column from responses and from every query surface. NoQuery is the option for values the caller must still see in some form.

NoQuery does not mask anything on its own; the hook does. Register it on AfterGet and AfterList, since each response path runs the one that matches the shape it serves; a to-one ?include= runs AfterGet because that is what the child's own GET /child/{id} runs.

The rejection deliberately names a NoQuery field, unlike a Hidden one. A hidden column's existence is itself the secret, so it has to be indistinguishable from a column that does not exist; a NoQuery column is right there in the response, so a precise error costs nothing and saves a developer hunting for a typo.

Default CRUD authentication

Prior to this section's introduction, an entity declaring neither OwnerField nor Access got zero enforcement from auto-CRUD: List, Get, Create, Update, and Delete were all reachable by an anonymous caller: an unauthenticated POST /api/<entity> returned 201 and persisted the row. Generated MCP tools inherited the same gap, since RegisterEntityMCPTools dispatches entity tools through the same router

  • middleware chain as REST.

Auto-CRUD is now secure-by-default: framework/crud's requireScope chokepoint requires an authenticated session (core/handler.GetUser) for every operation on an entity that declares none of OwnerField, Access, or Public. The three opt-outs, in the order they're checked:

  1. OwnerField set: the existing RequireOwner gate already requires an authenticated owner for every operation; the new session gate is redundant there and steps aside.
  2. Access declared (any operation, even a partial block): RBAC governs the entity. A blank Read leaves reads open to every caller; a blank Create, Update or Delete is refused to every caller (403), a wildcard role included. framework.Anyone opens an operation on purpose (Create: framework.Anyone for a contact form on an otherwise gated entity). The session gate does not layer an extra requirement on top.
  3. Public: true: a deliberate, full opt-out. Every operation, reads and writes, is open to anonymous callers: the framework's pre-secure-by-default behaviour for that entity. Meant for content that's genuinely public (a contact form, a blog's comments), not as a workaround for a 401 during development. An entity that wants public reads but gated writes uses Access instead (a blank Read and a real permission for each write).

The read posture has two questions, and Public answers only the first (may this caller read the entity). Which rows they see is Exposure.ReadScope: declared predicates on the entity's own columns, applied to every read surface (List, Get, count, cursor, stream, ?include=, ?rel.field=, the in-process API, typed queries), lifted for a caller holding the Unrestricted permission, or, when it is empty, for any signed-in caller. That empty-Unrestricted form is the "anonymous visitors see published rows, signed-in editors see drafts" posture, and it is deliberately weak: any signed-in user, not an editor role. Writes are not filtered by it. See entity-declarations → "Row-level read scoping".

A BeforeGet hook that scopes reads does filter writes. A record GET /<entity>/{id} answers 404 for, because the caller's own BeforeGet chain appends a clause that excludes it, is one PUT, PATCH, DELETE, a transition and each _batch update or delete item answer the same 404 for, with the bytes a missing id gets, and leave untouched. The check reads the row inside the write's transaction under the write's own tenant, owner and soft-delete scope, before any other step, so a hidden row, another owner's row and a missing one fail at the same point even for a body the write would have refused later. A hook error hides the row. In process it binds under crud.WithReadHooks, as GetOne does, and covers RestoreOne, PurgeOne and UpsertOne's conflict arm too. See hooks and transactions → "A BeforeGet scope binds writes too".

Because entity MCP tools dispatch through the router, this gate governs their calls automatically, and the framework attaches the entity's Access permission to each generated tool as its WithToolGate, so a caller who visibly lacks it does not see the tool in tools/list (see entity-declarations → "MCP Tools"). No mcp.WithToolGate/auth.MCPUser wiring of your own is needed for generated CRUD tools (that machinery remains for custom tools registered directly via app.MCP.RegisterTool; Endpoint.MCPHandler twins default to requiring an authenticated caller; see agent-ready).

The entity's auto-generated /{table}/llm.md runs the same requireScope chain as List. It documents exactly the entity List serves, so it answers to the same gate: an authenticated caller without the entity's read permission gets a 403 on the schema, not just on the rows. The field list is a disclosure in its own right: it names every non-Hidden column, its type and its enum set.

gofastr generate prints a warning listing every entity left publicly readable/writable (public: true), so a generated app never has open entities you didn't get told about. See entity-declarations → "Default CRUD authentication" for the app spec YAML shape.

gofastr audit lint flags this as rule unscoped-pii, and it inspects Go-declared entities (app.Entity(...) with an unscoped PII-shaped field exposed via auto-CRUD) as well as gofastr.yml: the cross-user exposure is identical either way.

Common mistakes

  • Relaxing CSP to fix a broken third-party script. Override only the directive you need (script-src, style-src); never replace default-src 'self' with 'unsafe-inline'.
  • Skipping Recovery because the app doesn't panic. It does eventually. Without it, a single panic terminates the request handler goroutine without writing a response, leaving the client hanging.
  • Composing CORS before RequestID. Preflights still need trace IDs; keep RequestID first.