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{returnvia.NewRouter(via.WithTrustedOrigin("https://example.com"),via.WithSecureCookies(),// TLS ends at the proxy, so req.TLS is nil herevia.WithSessionKey([]byte(os.Getenv("SESSION_KEY"))),via.WithSessionTTL(8*time.Hour),)}
Every action POST carries the tab's id in its body, a value same-origin script sets and the browser never attaches on its own. It is the CSRF token: an action on a live tab 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.
via.WithTrustedOrigin adds the origin check in front of every action, 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. A request with neither header fails. Behind a proxy, list the public origin: the allowlist is checked first, so a rewritten Host does not matter.
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.
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' 'unsafe-eval'
Plus a sha256 hash for each of via's inline scripts. No nonce, so nothing an injected tag could borrow.
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. It never varies per request, which is why Assets must be a constant of the type.
'unsafe-eval' is required: the bundled Datastar client compiles every data-* expression with the Function constructor. Without it every binding is dead in the browser while every server-side test passes.
How long an action waits for its tab's goroutine to pick it up. 503 stream busy.
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 is middleware.
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.