An action is an HTTP POST that any page on the web can aim at your server, so via treats the origin, the session and the tab as three separate checks. By default the tab check and the cookie flags are on and the origin check is off, which is right for localhost and wrong for production. Body and stream caps are router-wide With* options, and the CSP is derived from declared assets, never from a request.
funcnewRouter()*via.Router{key:=os.Getenv("VIA_SESSION_KEY")ifkey==""{log.Fatal("VIA_SESSION_KEY is unset: export 32+ random bytes")}returnvia.NewRouter(via.WithTrustedOrigin("https://example.com"),via.WithSecureCookies(),// the proxy sends no X-Forwarded-Protovia.WithSessionKey([]byte(key)),via.WithSessionTTL(8*time.Hour),)}
An action POST carries the tab's id in its body, a value same-origin script sets and the browser never attaches on its own. An action on a live unit runs only with that tab's id, and only under the session the tab connected with. A request carrying another session's cookie answers 403 session mismatch, even with the right id. A plain action runs on a fresh instance and never checks the id, so for it the origin check below is the only CSRF defence.
via.WithTrustedOrigin adds the origin check in front of every action, form submit and stream connect, in this order: an Origin on the allowlist passes; otherwise a Sec-Fetch-Site header must be same-origin or none; otherwise the Origin host must equal the request's Host, and be https when the request arrived over TLS or the proxy says X-Forwarded-Proto: https or Forwarded: proto=https. A request with neither header fails. A refusal is 403 forbidden origin. Behind a proxy, list the public origin: the allowlist is checked first, so a rewritten Host does not matter. A listed origin is compared as the browser sends it: host case, a default port and a trailing "/" do not matter, and a value with a path, query, fragment or userinfo panics at startup.
24h idle. Once less than half the TTL is left, a request re-saves the session and re-sends the cookie with a fresh Max-Age: one write per half-TTL, so a session can expire as little as half the TTL after its last request.
With no key set, via generates one per process and warns on the first session: cookies then die on every restart and are invalid on a second process. The cookie holds only a signed id, never data.
The proxy header is trusted as sent: a client that forges it only marks its own cookie Secure.
A response that resolved a session or sets the cookie is sent Cache-Control: private, no-store, so no shared cache hands one user's page to another and the back button does not show a signed-in page after logout. A live page gets the same, session or not, over any Cache-Control a middleware set, since its HTML carries the tab id. A plain page with no Cache-Control gets no-cache; one the app set is kept. The SSE connect gets at least no-cache, over any a middleware set. An action response with no session behind it gets none from via.
func(c*Checkout)Back(ctx*via.Ctx){// A next field of https://evil.example is dropped and logged.ctx.Redirect(ctx.Request().FormValue("next"))}func(c*Checkout)Pay(ctx*via.Ctx){ctx.RedirectExternal("https://pay.example/checkout/"+strconv.Itoa(c.order))}
via.Ctx.Redirect follows a relative path, or an absolute URL whose host is the request's Host or whose origin is listed with via.WithTrustedOrigin. Any other target is dropped and logged, like a javascript: one: the action answers with its render, and an OnInit answers 500. The host is read the way a browser reads it, so https://[email protected] and https://evil.example\@app.example both name evil.example. via.Ctx.RedirectExternal leaves the site, for an OAuth provider or a payment page; it still refuses any scheme but http and https. Build its target yourself rather than passing one from the request.
via.Ctx.Session returns the browser's session. via.Session.Put stores one JSON value and issues the cookie on first write; via.Session.Get reads it back as a type. Both handlers call via.Session.Rotate, which moves the data to a fresh cookie id and deletes the old one, so an id an attacker planted before the login, or copied during it, resolves to nothing afterwards.
cookie id: none
retired by the last Rotate: none
Session.ID(): none
Try this
Sign in, sign out, sign in again: each Rotate lists the id it retired, and Session.ID() never changes.
Sign out and read the Wire pane: one POST, answered with a patch showing the next id. Its Set-Cookie is hidden from page script; the sign-in response below shows one.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("strings""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")// SessionUser is the session's one value. Cookie and Retired exist only so// the demo can show what each Rotate did: via never exposes the cookie id// except as Rotate's return value.typeSessionUserstruct{NamestringCookiestringRetiredstring}// SecurityAuth signs in with a native form and signs out with an action,// rotating the session id on both.typeSecurityAuthstruct{userSessionUsersidstring}func(a*SecurityAuth)OnInit(ctx*via.Ctx)error{returna.load(ctx)}// Logout rewrites the session OnInit already read.func(a*SecurityAuth)OnReload(ctx*via.Ctx)error{returna.load(ctx)}func(a*SecurityAuth)load(ctx*via.Ctx)error{a.user,_=ctx.Session().Get[SessionUser]()a.sid=ctx.Session().ID()returnnil}// Login is a native submit, so it can Redirect: the browser sends the new// cookie only on the request after this one.func(a*SecurityAuth)Login(ctx*via.Ctx){name:=strings.TrimSpace(ctx.Request().FormValue("name"))ifname==""{name="anonymous"}ifr:=[]rune(name);len(r)>24{name=string(r[:24])}// Rotate before the write: an id planted before the login never holds// the signed-in value.cookie:=ctx.Session().Rotate()ctx.Session().Put(SessionUser{Name:name,Cookie:cookie,Retired:a.user.Cookie})mount,_,_:=strings.Cut(ctx.Request().URL.Path,"/_via/")ctx.Redirect(mount)}// Logout is an auth-state change too, so it rotates: a cookie captured while// signed in resolves to nothing afterwards.func(a*SecurityAuth)Logout(ctx*via.Ctx){cookie:=ctx.Session().Rotate()ctx.Session().Put(SessionUser{Cookie:cookie,Retired:a.user.Cookie})}// short prints enough of a 128-bit id to tell two apart and keeps the rest// out of the DOM, where the cookie's HttpOnly flag would not protect it.funcshort(idstring)string{ifid==""{return"none"}returnid[:min(8,len(id))]+"…"}func(a*SecurityAuth)ids()h.H{returnh.Ul(h.Class("reflist"),h.Li(h.Str("cookie id: "),h.Code(h.Str(short(a.user.Cookie)))),h.Li(h.Str("retired by the last Rotate: "),h.Code(h.Str(short(a.user.Retired)))),h.Li(h.Str("Session.ID(): "),h.Code(h.Str(short(a.sid)))),)}func(a*SecurityAuth)View()h.H{ifa.user.Name==""{returnh.Div(via.PostForm(a.Login,h.Div(h.Class("row"),h.Label(h.For("auth-name"),h.Str("name")),h.Input(h.ID("auth-name"),h.Name("name"),h.AutoComplete("off"),h.Placeholder("anything")),h.Button(h.Type("submit"),h.Str("sign in")),),),a.ids(),)}returnh.Div(h.Div(h.Class("row"),h.Str("signed in as "),h.Strong(h.Str(a.user.Name)),h.Button(on.Click(a.Logout),h.Str("sign out")),),a.ids(),)}
A stable id that survives Rotate. Key your own data by it; it grants nothing.
Rotate and the first Put need a response to carry the cookie: call them in OnInit or an action, not in a Tick or Listen handler. Auth is a check in OnInit and a branch in View; composition and the hooks are on Compositions.
The default store is a map in the process, so a restart signs everyone out and a second process sees none of the first one's sessions. via.WithSessionStore takes a via.SessionStore: Load, Save and Delete of opaque bytes by id, with a TTL on Save. Pair it with a stable via.WithSessionKey: the key keeps the cookie valid, the store keeps the data behind it.
Two requests writing one session at once can lose a write against a plain store. Implement via.VersionedSessionStore (a conditional SaveIf) and via retries the merge instead. via.NewMemorySessionStore is the default and implements both. A store that fails a Load answers 503 rather than serving the request signed out; bound its calls with via.WithSessionStoreTimeout (default 5s).
Nothing loads from another origin unless a directive below widens it.
script-src 'self' 'nonce-
A nonce fresh per document, which Datastar compiles expressions under, plus a sha256 hash for each of via's inline scripts.
'unsafe-eval'
No script on the page may evaluate a string. via.WithUnsafeEval adds it, for a library that needs it.
style-src 'self'
via emits no style element and no style attribute.
object-src 'none'
Fixed.
base-uri 'self'
Fixed.
form-action 'self'
Fixed.
frame-ancestors 'self'
Fixed.
img-src
Added only when a preload names an image on another origin.
The policy is built once per mount from the router's via.WithHead assets plus the page's PageMeta().Assets: a script or stylesheet origin you declare joins its directive, an inline body joins by hash. Its shape never varies per request, which is why Assets must be a constant of the type; only the nonce changes, once per document.
Datastar compiles each data-* expression as a script carrying the nonce, never through Function. The nonce holds for the document's life, so expressions that arrive later in a patch compile too. A second policy, from middleware or a proxy, that lacks the nonce blocks all of them.
A library that compiles code from strings (eval, new Function, setTimeout("…")) needs 'unsafe-eval'. via.WithUnsafeEval adds it to every page's script-src and keeps the nonce and hashes. A string that reaches one of those calls then runs as script, so via warns at startup while it is set. Prefer a build of the library that does not evaluate strings.
Datastar also stamps that nonce on every <script> a patch inserts, and on a text/javascript response, so a script that arrives in a live push runs even though the same element in the first document is blocked. That is why h.El("script", …) panics: scripts belong in Meta.Assets, where the policy names them.
The nonce is only as fresh as the document. A plain page is sent Cache-Control: no-cache so each view is refetched with its own; an app that sets a longer-lived Cache-Control on a page shares that page's nonce with every viewer the cache serves, and an injection that reads it once can reuse it.
How long an action waits for its tab's goroutine to pick it up, 503 stream busy; and for a stream still connecting, at most 2s, 410.
The stream cap is router-wide, with no per-client share: one client can hold all of it. Set it to what the machine has memory for, since each stream holds a goroutine and a composition tree for the tab's life. A per-client limit belongs at the proxy, which sees the client's address; see Limits and dead peers.
funcsecurityHeaders(nexthttp.Handler)http.Handler{returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){w.Header().Set("Referrer-Policy","strict-origin-when-cross-origin")w.Header().Set("Strict-Transport-Security","max-age=63072000")next.ServeHTTP(w,r)})}typestatusWriterstruct{http.ResponseWriterstatusint}func(s*statusWriter)WriteHeader(codeint){s.status=codes.ResponseWriter.WriteHeader(code)}// Unwrap lets via reach the real writer's Flush; without it every live// page's stream connect answers 500.func(s*statusWriter)Unwrap()http.ResponseWriter{returns.ResponseWriter}funcaccessLog(nexthttp.Handler)http.Handler{returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){start:=time.Now()sw:=&statusWriter{ResponseWriter:w,status:http.StatusOK}next.ServeHTTP(sw,r)slog.Info("request","method",r.Method,"path",r.URL.Path,"status",sw.status,"took",time.Since(start))})}funcmain(){app:=newRouter()// *via.Router is an http.Handlerhttp.ListenAndServe(":8080",accessLog(securityHeaders(app)))}
via.Router is an http.Handler, so logging, rate limits, auth redirects and extra headers are ordinary net/http wrappers; via has no middleware API of its own. A wrapper sees page GETs, action POSTs under /_via/a/ and stream connects under /_via/sse alike. A wrapper that replaces the ResponseWriter must expose Unwrap() http.ResponseWriter, or via cannot flush and every stream connect answers 500.