Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package session implements HMAC-signed cookie sessions for the admin
// UI. The cookie is the only state: it is signed, verified and expired
// here, and no session table exists anywhere.
//
// The cookie value is base64url(payload) + "." + base64url(signature)
// where payload is JSON {"d": {…}, "iat": unix-seconds}. Sessions are
// invalidated by signature mismatch or expiry, so users simply log in
// again after the signing key changes.
package session
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// CookieName is the session cookie name.
const CookieName = "volumen_session"
type contextKey struct{}
// Store signs and verifies session cookies.
type Store struct {
secret []byte
ttl time.Duration
secure bool
}
// New creates a session store. An empty secret is replaced with an
// ephemeral random one (sessions then do not survive restarts).
func New(secret string, ttl time.Duration, secure bool) *Store {
key := []byte(secret)
if len(key) == 0 {
key = make([]byte, 64)
if _, err := rand.Read(key); err != nil {
slog.Warn("session: random source failed", "error", err)
}
slog.Warn("session: session_key is empty; using an ephemeral random secret")
}
return &Store{secret: key, ttl: ttl, secure: secure}
}
// Secure reports whether cookies carry the Secure flag.
func (s *Store) Secure() bool { return s.secure }
// Session is a mutable key-value bag loaded for one request.
type Session struct {
data map[string]string
dirty bool
store *Store
}
// Get returns the value for key, or "".
func (s *Session) Get(key string) string { return s.data[key] }
// Set stores a value and marks the session for re-signing.
func (s *Session) Set(key, value string) {
if s.data[key] == value {
return
}
s.data[key] = value
s.dirty = true
}
// Delete removes a key.
func (s *Session) Delete(key string) {
if _, ok := s.data[key]; !ok {
return
}
delete(s.data, key)
s.dirty = true
}
// Clear empties the session.
func (s *Session) Clear() {
if len(s.data) == 0 {
return
}
s.data = map[string]string{}
s.dirty = true
}
// Abandon empties the session and clears the dirty flag, for a response
// that expires the cookie instead of re-signing it. Save would otherwise
// append a fresh cookie after Destroy's expiring one, and the browser
// applies the last header.
func (s *Session) Abandon() {
s.data = map[string]string{}
s.dirty = false
}
type payload struct {
Data map[string]string `json:"d"`
Iat int64 `json:"iat"`
}
// Load reads and verifies the session cookie from the request; a
// missing or invalid cookie yields an empty session.
func (s *Store) Load(r *http.Request) *Session {
sess := &Session{data: map[string]string{}, store: s}
cookie, err := r.Cookie(CookieName)
if err != nil || cookie.Value == "" {
return sess
}
data, ok := s.verify(cookie.Value)
if !ok {
return sess
}
var p payload
if err := json.Unmarshal(data, &p); err != nil {
return sess
}
if s.ttl > 0 && time.Since(time.Unix(p.Iat, 0)) > s.ttl {
return sess
}
if p.Data != nil {
sess.data = p.Data
} else {
sess.data = map[string]string{}
}
return sess
}
func (s *Store) verify(value string) ([]byte, bool) {
// The signature is appended after the last dot; the body is base64url
// and carries none.
body, sig, found := strings.CutLast(value, ".")
if !found {
return nil, false
}
data, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return nil, false
}
mac, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil {
return nil, false
}
if !hmac.Equal(mac, s.sign(body)) {
return nil, false
}
return data, true
}
func (s *Store) sign(body string) []byte {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(body))
return mac.Sum(nil)
}
// Save re-signs and sets the cookie when the session changed.
func (s *Store) Save(w http.ResponseWriter, sess *Session) {
if !sess.dirty {
return
}
sess.dirty = false
body, err := json.Marshal(payload{Data: sess.data, Iat: time.Now().Unix()})
if err != nil {
slog.Warn("session: cannot encode payload", "error", err)
return
}
encoded := base64.RawURLEncoding.EncodeToString(body) +
"." + base64.RawURLEncoding.EncodeToString(s.sign(base64.RawURLEncoding.EncodeToString(body)))
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: encoded,
Path: "/",
MaxAge: int(s.ttl.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteStrictMode,
})
}
// Destroy expires the session cookie.
func (s *Store) Destroy(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteStrictMode,
})
}
// Middleware loads the session before the handler and persists it
// afterwards when it changed. The response header is not flushed until
// the first body write (or the end of the request), so the session
// cookie survives handlers that only set a status code.
func (s *Store) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess := s.Load(r)
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r.WithContext(WithContext(r.Context(), sess)))
if sess.dirty {
s.Save(recorder, sess)
}
recorder.flush()
})
}
// WithContext returns a context carrying sess the way the middleware
// does. A chain that must not buffer the response, a large download,
// attaches the session through this instead: the middleware records the
// whole body so it can still set a cookie after the handler ran, which
// is exactly the memory a streamed response must not pay. A session
// attached this way is read-only in effect: nothing flushes its dirty
// flag, so mutations do not persist.
func WithContext(ctx context.Context, sess *Session) context.Context {
return context.WithValue(ctx, contextKey{}, sess)
}
// FromContext returns the session attached to the request, or an empty
// detached session when the middleware is not installed.
func FromContext(ctx context.Context) *Session {
if sess, ok := ctx.Value(contextKey{}).(*Session); ok {
return sess
}
return &Session{data: map[string]string{}}
}
// statusRecorder buffers the response so the session cookie can be
// added after the handler ran: net/http snapshots headers at the first
// body byte otherwise.
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
sent bool
buf bytes.Buffer
}
func (r *statusRecorder) WriteHeader(code int) {
if !r.wroteHeader {
r.status = code
r.wroteHeader = true
}
}
func (r *statusRecorder) Write(b []byte) (int, error) {
r.wroteHeader = true
return r.buf.Write(b)
}
// flush sends the recorded status and buffered body once.
func (r *statusRecorder) flush() {
if r.sent {
return
}
r.sent = true
r.ResponseWriter.WriteHeader(r.status)
if r.buf.Len() > 0 {
_, _ = r.ResponseWriter.Write(r.buf.Bytes())
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package session
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/synctest"
"time"
)
func TestSetSaveLoadRoundTrip(t *testing.T) {
store := New("secret-key", time.Hour, false)
rec := httptest.NewRecorder()
sess := &Session{data: map[string]string{}, store: store}
sess.Set("user", "petr")
store.Save(rec, sess)
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("cookies = %v", cookies)
}
if cookies[0].Name != CookieName || cookies[0].HttpOnly != true ||
cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("cookie attrs = %+v", cookies[0])
}
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookies[0])
loaded := store.Load(req)
if loaded.Get("user") != "petr" {
t.Fatalf("loaded = %v", loaded.data)
}
}
func TestTamperedCookieRejected(t *testing.T) {
store := New("secret-key", time.Hour, false)
rec := httptest.NewRecorder()
sess := &Session{data: map[string]string{}, store: store}
sess.Set("user", "petr")
store.Save(rec, sess)
value := rec.Result().Cookies()[0].Value
tampered := value[:len(value)-2] + "xx"
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: tampered})
if got := store.Load(req).Get("user"); got != "" {
t.Fatalf("tampered cookie accepted: %q", got)
}
}
func TestForeignSignatureRejected(t *testing.T) {
other := New("other-key", time.Hour, false)
store := New("secret-key", time.Hour, false)
rec := httptest.NewRecorder()
sess := &Session{data: map[string]string{}, store: other}
sess.Set("user", "petr")
other.Save(rec, sess)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(rec.Result().Cookies()[0])
if got := store.Load(req).Get("user"); got != "" {
t.Fatalf("foreign cookie accepted: %q", got)
}
}
// synctest advances the clock past the one-nanosecond lifetime, so the
// test needs no sleep and cannot be flaky on a loaded machine.
func TestExpiredSessionRejected(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := New("secret-key", time.Nanosecond, false)
rec := httptest.NewRecorder()
sess := &Session{data: map[string]string{}, store: store}
sess.Set("user", "petr")
store.Save(rec, sess)
time.Sleep(time.Second)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(rec.Result().Cookies()[0])
if got := store.Load(req).Get("user"); got != "" {
t.Fatalf("expired cookie accepted: %q", got)
}
})
}
func TestMissingCookieYieldsEmptySession(t *testing.T) {
store := New("k", time.Hour, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess := store.Load(req)
if sess.Get("user") != "" || sess.dirty {
t.Fatalf("session = %v dirty=%v", sess.data, sess.dirty)
}
}
func TestCleanSessionNotPersisted(t *testing.T) {
store := New("k", time.Hour, false)
rec := httptest.NewRecorder()
store.Save(rec, &Session{data: map[string]string{}, store: store})
if len(rec.Result().Cookies()) != 0 {
t.Fatal("cookie set for clean session")
}
}
func TestSetSameValueNotDirty(t *testing.T) {
store := New("k", time.Hour, false)
sess := &Session{data: map[string]string{"a": "1"}, store: store}
sess.Set("a", "1")
if sess.dirty {
t.Fatal("dirty for unchanged value")
}
}
func TestDeleteAndClear(t *testing.T) {
store := New("k", time.Hour, false)
sess := &Session{data: map[string]string{"a": "1", "b": "2"}, store: store}
sess.Delete("a")
if sess.Get("a") != "" || !sess.dirty {
t.Fatal("delete failed")
}
sess.dirty = false
sess.Delete("missing")
if sess.dirty {
t.Fatal("deleting missing key dirtied the session")
}
sess.Clear()
if sess.Get("b") != "" || !sess.dirty {
t.Fatal("clear failed")
}
sess.dirty = false
sess.Clear()
if sess.dirty {
t.Fatal("clearing empty session dirtied it")
}
}
func TestMiddlewareRoundTrip(t *testing.T) {
store := New("k", time.Hour, false)
handler := store.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess := FromContext(r.Context())
sess.Set("user", "petr")
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("cookies = %v", cookies)
}
handler2 := store.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := FromContext(r.Context()).Get("user"); got != "petr" {
t.Errorf("user = %q", got)
}
}))
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.AddCookie(cookies[0])
handler2.ServeHTTP(rec2, req2)
}
func TestFromContextWithoutMiddleware(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess := FromContext(req.Context())
sess.Set("x", "y")
if sess.Get("x") != "y" {
t.Fatal("detached session broken")
}
}
func TestGarbageCookieIgnored(t *testing.T) {
store := New("k", time.Hour, false)
for _, value := range []string{"nodot", ".", "a.b", strings.Repeat("!", 50)} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: value})
store.Load(req) // must not panic
}
}