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.)
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:
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
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)", })
| Header | Default |
|---|---|
Content-Security-Policy | default-src 'self'; img-src 'self' data:; object-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'self' |
X-Content-Type-Options | nosniff (always, not configurable) |
Referrer-Policy | no-referrer |
X-Frame-Options | DENY |
Permissions-Policy | geolocation=(), microphone=(), camera=() |
Strict-Transport-Security | max-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:
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
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
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)
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:
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.
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):
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)
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
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
CacheControlwithout 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 echoesX-Request-ID.Recovery(): turns panics into500with structured log line (ano-store500without the handler's representation headers), or cuts the connection when the response had already started.Logging()/LoggingFn(getLogger)/LoggingWithWriter(io.Writer): structured request log.LoggingFnreads 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 unsampledLogging()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 isOPTIONScarrying bothOriginandAccess-Control-Request-Method; it answers204for an allowed origin and403otherwise. Any otherOPTIONSreaches your handler. Under an origin allow-list every response carriesVary: 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.Serverrefuses a browserOriginthat is not same-origin with the request. Pin the authority withSetAllowedHosts(orSetRequireLoopbackHost(true)) when the transport is local; allow tunnels withSetAllowedOrigins.gofastr devpins 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 pinsHostto the authority it bound. Its chat page carries the bearer token in a meta tag, so an unpinnedHostwould let a rebound page read the token and then drive the agent.kiln serve: pinsHostto loopback whenever--addrbound a loopback address.kilnalso 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 pinHostto their own loopback address plusConfig.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:
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
- 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:
OwnerFieldset: the existingRequireOwnergate already requires an authenticated owner for every operation; the new session gate is redundant there and steps aside.Accessdeclared (any operation, even a partial block): RBAC governs the entity. A blankReadleaves reads open to every caller; a blankCreate,UpdateorDeleteis refused to every caller (403), a wildcard role included.framework.Anyoneopens an operation on purpose (Create: framework.Anyonefor a contact form on an otherwise gated entity). The session gate does not layer an extra requirement on top.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 usesAccessinstead (a blankReadand 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 replacedefault-src 'self'with'unsafe-inline'. - Skipping
Recoverybecause 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; keepRequestIDfirst.