Watch
1
0
Fork
You've already forked RedFlag
0

code review: 12-finding fan-out — fixes across server, agent, web

HIGH:
- URL sync race: page useEffect now preserves filter params from useFilterUrl
- useFilterUrl: document two-effect pattern (state→URL and URL→state)
- Fleet-join: store nil (not &"") for absent MachineID/PublicKeyFingerprint
- agents.go: same NULL fix for standard registration path

MEDIUM:
- Test assertions: replace CSS class checks with user-visible element assertions
- Updates vuln toggle: fixed-set like other quick filters (was toggling)
- ConfigureSecrets route: restore to welcome-mode server
- Config upgrade: recursive mergeMissingKeys for nested sub-fields + test

LOW:
- LiveOperations: wire FilterBar pills/clearAll/activeCount
- HashTOTPSeed: remove dead code replaced by encrypted storage (migration 058)
- auditor.go: replace unsafe reflect with Recorder wrapper (AUDIT-002)

History filter panel kept as-is (collapsible pattern intentional).
Agents.tsx duplicate buildFilterPills was a false positive (already resolved).
This commit is contained in:
Fimeg 2026-06-11 17:38:08 -04:00
commit a4d585c79c
14 changed files with 1109 additions and 401 deletions

View file

@ -320,7 +320,19 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
}
}
// Create new agent
// Create new agent. Only set MachineID/PublicKeyFingerprint when non-empty;
// the Agent model stores them as *string so nil maps to SQL NULL, avoiding
// unique-index violations that &"" would cause on the machine_id partial
// unique index WHERE machine_id IS NOT NULL.
var machineID *string
if req.MachineID != "" {
machineID = &req.MachineID
}
var pubKeyFP *string
if req.PublicKeyFingerprint != "" {
pubKeyFP = &req.PublicKeyFingerprint
}
agent := &models.Agent{
ID: uuid.Must(uuid.NewV4()),
Hostname: req.Hostname,
@ -329,8 +341,8 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
CurrentVersion: req.AgentVersion,
MachineID: &req.MachineID,
PublicKeyFingerprint: &req.PublicKeyFingerprint,
MachineID: machineID,
PublicKeyFingerprint: pubKeyFP,
LastSeen: time.Now().UTC(),
Status: "online",
Metadata: models.JSONB{},

View file

@ -1,7 +1,9 @@
// SEC-025: Fleet-join endpoint. A standalone host calls this to migrate to
// fleet mode. Two-factor: registration token (server-originated) + TOTP code
// (host-originated seed). On success the agent is registered and the server
// returns its authority key(s) so the host can retire its local authority.
// proving live possession of the seed enrolled at token creation. The seed is
// held server-side encrypted and never travels on this channel. On success
// the agent is registered and the server returns its authority key(s) so the
// host can retire its local authority.
package handlers
import (
@ -35,11 +37,12 @@ func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQue
}
}
// FleetJoinRequest is the standalone host's join-fleet submission.
// FleetJoinRequest is the standalone host's join-fleet submission. The TOTP
// seed never crosses this channel — the server holds it encrypted from token
// creation and only the 6-digit code travels here.
type FleetJoinRequest struct {
RegistrationToken string `json:"registration_token" binding:"required"`
TOTPCode string `json:"totp_code" binding:"required"`
TOTPSeed string `json:"totp_seed" binding:"required"`
// Standard agent registration fields (same as RegisterAgent).
Hostname string `json:"hostname" binding:"required"`
@ -79,23 +82,22 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
}
// Step 2: Check that this token requires TOTP (fleet-join tokens only).
if tokenInfo.TOTPSeedHash == nil {
if len(tokenInfo.TOTPSeedEncrypted) == 0 {
log.Printf("[SECURITY] [server] [fleet-join] no_totp_required token_id=%s", tokenInfo.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "this token does not require 2FA — use the standard registration endpoint"})
return
}
// Step 3: Validate the TOTP seed. The host sends the plaintext seed;
// we hash it and compare against what's stored on the token.
seedHash := security.HashTOTPSeed(req.TOTPSeed)
if seedHash != *tokenInfo.TOTPSeedHash {
log.Printf("[SECURITY] [server] [fleet-join] seed_mismatch token_id=%s", tokenInfo.ID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "TOTP seed does not match this registration token"})
// Step 3: Validate the TOTP code against the server-held seed. The seed
// was stored encrypted at token creation and never crosses this channel;
// a valid code proves the host holds the seed right now.
seed, err := h.tokenQueries.DecryptTOTPSeed(tokenInfo)
if err != nil {
log.Printf("[ERROR] [server] [fleet-join] seed_decrypt_failed token_id=%s error=%q", tokenInfo.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
// Step 4: Validate the TOTP code against the seed.
if !security.ValidateTOTPCode(req.TOTPSeed, req.TOTPCode) {
if !security.ValidateTOTPCode(seed, req.TOTPCode) {
log.Printf("[SECURITY] [server] [fleet-join] invalid_totp token_id=%s", tokenInfo.ID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid TOTP code"})
return
@ -122,6 +124,19 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
}
defer tx.Rollback()
// Only set MachineID/PublicKeyFingerprint when non-empty; the Agent model
// stores them as *string so nil maps to SQL NULL, matching the standard
// registration path and avoiding unique-index violations that &"" would
// cause when the machine_id column has a partial UNIQUE index.
var machineID *string
if req.MachineID != "" {
machineID = &req.MachineID
}
var pubKeyFP *string
if req.PublicKeyFingerprint != "" {
pubKeyFP = &req.PublicKeyFingerprint
}
agent := &models.Agent{
ID: uuid.Must(uuid.NewV4()),
Hostname: req.Hostname,
@ -130,8 +145,8 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
CurrentVersion: req.AgentVersion,
MachineID: &req.MachineID,
PublicKeyFingerprint: &req.PublicKeyFingerprint,
MachineID: machineID,
PublicKeyFingerprint: pubKeyFP,
LastSeen: time.Now().UTC(),
Status: "online",
Metadata: models.JSONB{},

View file

@ -1,21 +1,20 @@
// Package routeaudit enforces ETHOS #2 (no unauthenticated endpoints) by
// walking every registered route at startup and verifying each carries auth
// auditing every registered route at startup and verifying each carries auth
// middleware matching its trust boundary. Routes missing auth that are not on
// an explicit public allowlist cause the server to refuse boot with a CRITICAL
// log and os.Exit(1).
//
// The audit uses reflection to walk Gin internal radix trees (engine.trees)
// and classifies each handler in every route chain by its fully-qualified
// function name. No new dependencies — stdlib reflect, runtime, os, log only.
// A Recorder wrapper intercepts route registrations at the time they're made,
// capturing the full flattened handler chain (inherited group middleware +
// route-specific handlers) for later validation. This avoids the unsafe
// reflection that was previously needed to walk Gin's internal radix trees.
package routeaudit
import (
"log"
"os"
"path"
"reflect"
"runtime"
"strings"
"unsafe"
"github.com/gin-gonic/gin"
)
@ -48,6 +47,7 @@ var PublicPathSet = map[string]bool{
// Agent registration and token renewal — public entry points (guarded
// internally by registration tokens and machine binding respectively).
"/api/v1/agents/register": true,
"/api/v1/fleet-join": true,
"/api/v1/agents/renew": true,
// Downloads — public for bootstrapping. Artifact download is public so
@ -64,156 +64,264 @@ var PublicPathSet = map[string]bool{
// matching its trust boundary.
type Auditor struct {
publicPaths map[string]bool
authClasses map[uintptr]string // middleware code pointer -> boundary class
}
// NewAuditor returns an Auditor initialised with the public route allowlist.
// Auth middleware must be registered via RegisterAuth before AuditAndExit.
func NewAuditor() *Auditor {
return &Auditor{
publicPaths: PublicPathSet,
authClasses: map[uintptr]string{},
}
}
// classifyHandler returns the auth boundary of a handler by inspecting its
// fully-qualified function name (obtained via runtime.FuncForPC). Returns an
// empty string if the handler is not a recognised auth middleware.
// RegisterAuth records h as a recognised auth middleware for the given trust
// boundary class ("web", "agent", "metrics", ...). Returns the Auditor for
// chaining.
//
// The match is on the bare function name, NOT the package-qualified form:
// when the compiler inlines a closure-returning constructor across packages,
// the closure is renamed after its caller (e.g. middleware.AuthMiddleware's
// closure becomes "main.main.AuthMiddleware.func1") and the package qualifier
// disappears. Matching "middleware.AuthMiddleware" misclassified every agent
// route and refused boot — 122 container restarts before this was caught.
// Classification is by code-pointer identity, not symbol name. Register the
// EXACT instance used at route registration: when the compiler inlines a
// middleware constructor, each inline site gets its own copy of the closure
// body, so two separate constructor calls are not guaranteed to share a code
// pointer. The wiring rule is one middleware instance per trust boundary,
// stored in a variable, used at every route, registered here.
//
// Order matters: "WebAuthMiddleware" contains "AuthMiddleware", so the web
// check runs first.
//
// Classification rules:
// - *WebAuthMiddleware* -> web JWT boundary
// - *AuthMiddleware* -> agent JWT boundary
// - *MetricsBearerAuth* -> metrics token boundary
func classifyHandler(h gin.HandlerFunc) string {
name := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name()
if strings.Contains(name, "WebAuthMiddleware") {
return "web"
}
if strings.Contains(name, "AuthMiddleware") {
return "agent"
}
if strings.Contains(name, "MetricsBearerAuth") {
return "metrics"
}
return ""
// History: classification used to parse runtime.FuncForPC symbol names. The
// compiler inlined middleware.AuthMiddleware across packages, renaming its
// closure to "main.main.AuthMiddleware.func1" — the package-qualified match
// missed it and the server refused boot (122 container restarts). The bare
// substring match that replaced it would silently classify ANY symbol
// containing "AuthMiddleware" as an auth boundary — a false negative waiting
// for a colliding name. Pointer identity over shared instances has neither
// failure mode: copies of one func value always compare equal, and an
// unregistered wrapper or stray fresh constructor call fails the audit
// loudly at boot instead of passing silently.
func (a *Auditor) RegisterAuth(class string, h gin.HandlerFunc) *Auditor {
a.authClasses[reflect.ValueOf(h).Pointer()] = class
return a
}
// unsafeHandlerChain extracts a []gin.HandlerFunc from a reflect.Value
// representing a HandlersChain obtained from an unexported struct field.
// Go 1.23+ prevents reflect.Value.Interface() on unexported fields; this
// function reads the slice data directly via unsafe to bypass the check.
func unsafeHandlerChain(v reflect.Value) []gin.HandlerFunc {
// Mirror of reflect.Value internal layout — three-pointer struct.
// Stable since Go 1.0 in practice; the type pointer, data pointer,
// and flags word.
type rv struct {
typ unsafe.Pointer
ptr unsafe.Pointer
flag uintptr
}
rvp := (*rv)(unsafe.Pointer(&v))
// For a slice, rvp.ptr points to the slice header: {data, len, cap}.
type sliceHeader struct {
data unsafe.Pointer
len int
cap int
}
sh := (*sliceHeader)(rvp.ptr)
return unsafe.Slice((*gin.HandlerFunc)(sh.data), sh.len)
// classifyHandler returns the auth boundary class of a handler, or an empty
// string if the handler is not a registered auth middleware.
func (a *Auditor) classifyHandler(h gin.HandlerFunc) string {
return a.authClasses[reflect.ValueOf(h).Pointer()]
}
// walkNode recursively walks a Gin radix tree node, concatenating path
// segments to build the full route path, and checks each route handler
// chain for auth middleware. Returns a list of paths that lack auth and
// are not on the public allowlist.
func (a *Auditor) walkNode(node reflect.Value, prefix string) []string {
// routeRecord captures a single auditable route with its full handler chain.
type routeRecord struct {
method string
path string
handlers []gin.HandlerFunc
}
// Recorder wraps gin.Engine to intercept route registrations for audit.
// Each route registration records the full flattened handler chain
// (inherited group middleware + route-specific handlers) for validation
// by Auditor.Validate. All routing methods proxy through to the real engine.
type Recorder struct {
engine *gin.Engine
routes []routeRecord
}
// RecorderGroup wraps gin.RouterGroup to intercept route registrations
// within a group context, recording the full handler chain including
// inherited parent-group middleware. All routing methods proxy through
// to the real router group.
type RecorderGroup struct {
recorder *Recorder
group *gin.RouterGroup
prefix string
}
// NewRecorder creates a Recorder wrapping the given gin engine.
func NewRecorder(engine *gin.Engine) *Recorder {
return &Recorder{engine: engine}
}
// --- Recorder methods (engine-level route registration) ---
// GET records and registers a GET route.
func (r *Recorder) GET(path string, handlers ...gin.HandlerFunc) {
r.record("GET", path, handlers)
r.engine.GET(path, handlers...)
}
// POST records and registers a POST route.
func (r *Recorder) POST(path string, handlers ...gin.HandlerFunc) {
r.record("POST", path, handlers)
r.engine.POST(path, handlers...)
}
// PUT records and registers a PUT route.
func (r *Recorder) PUT(path string, handlers ...gin.HandlerFunc) {
r.record("PUT", path, handlers)
r.engine.PUT(path, handlers...)
}
// DELETE records and registers a DELETE route.
func (r *Recorder) DELETE(path string, handlers ...gin.HandlerFunc) {
r.record("DELETE", path, handlers)
r.engine.DELETE(path, handlers...)
}
// PATCH records and registers a PATCH route.
func (r *Recorder) PATCH(path string, handlers ...gin.HandlerFunc) {
r.record("PATCH", path, handlers)
r.engine.PATCH(path, handlers...)
}
// HEAD records and registers a HEAD route.
func (r *Recorder) HEAD(path string, handlers ...gin.HandlerFunc) {
r.record("HEAD", path, handlers)
r.engine.HEAD(path, handlers...)
}
// OPTIONS records and registers an OPTIONS route.
func (r *Recorder) OPTIONS(path string, handlers ...gin.HandlerFunc) {
r.record("OPTIONS", path, handlers)
r.engine.OPTIONS(path, handlers...)
}
// Use adds middleware to the engine. Proxied through to gin; engine-handler
// state is read from the real engine at route-registration time.
func (r *Recorder) Use(middleware ...gin.HandlerFunc) {
r.engine.Use(middleware...)
}
// Group creates a new recorder group. The returned RecorderGroup wraps the
// real gin.RouterGroup and records all routes registered on it.
func (r *Recorder) Group(relativePath string, handlers ...gin.HandlerFunc) *RecorderGroup {
realGroup := r.engine.Group(relativePath, handlers...)
prefix := path.Join("/", relativePath)
return &RecorderGroup{
recorder: r,
group: realGroup,
prefix: prefix,
}
}
// record captures a route registered at engine level. The full handler chain
// includes any engine-level middleware (added via Use) plus route-specific
// handlers.
func (r *Recorder) record(method, routePath string, handlers []gin.HandlerFunc) {
fullHandlers := make([]gin.HandlerFunc, 0, len(r.engine.Handlers)+len(handlers))
fullHandlers = append(fullHandlers, r.engine.Handlers...)
fullHandlers = append(fullHandlers, handlers...)
r.routes = append(r.routes, routeRecord{
method: method,
path: routePath,
handlers: fullHandlers,
})
}
// --- RecorderGroup methods (group-level route registration) ---
// GET records and registers a GET route on this group.
func (g *RecorderGroup) GET(path string, handlers ...gin.HandlerFunc) {
g.record("GET", path, handlers)
g.group.GET(path, handlers...)
}
// POST records and registers a POST route on this group.
func (g *RecorderGroup) POST(path string, handlers ...gin.HandlerFunc) {
g.record("POST", path, handlers)
g.group.POST(path, handlers...)
}
// PUT records and registers a PUT route on this group.
func (g *RecorderGroup) PUT(path string, handlers ...gin.HandlerFunc) {
g.record("PUT", path, handlers)
g.group.PUT(path, handlers...)
}
// DELETE records and registers a DELETE route on this group.
func (g *RecorderGroup) DELETE(path string, handlers ...gin.HandlerFunc) {
g.record("DELETE", path, handlers)
g.group.DELETE(path, handlers...)
}
// PATCH records and registers a PATCH route on this group.
func (g *RecorderGroup) PATCH(path string, handlers ...gin.HandlerFunc) {
g.record("PATCH", path, handlers)
g.group.PATCH(path, handlers...)
}
// HEAD records and registers a HEAD route on this group.
func (g *RecorderGroup) HEAD(path string, handlers ...gin.HandlerFunc) {
g.record("HEAD", path, handlers)
g.group.HEAD(path, handlers...)
}
// OPTIONS records and registers an OPTIONS route on this group.
func (g *RecorderGroup) OPTIONS(path string, handlers ...gin.HandlerFunc) {
g.record("OPTIONS", path, handlers)
g.group.OPTIONS(path, handlers...)
}
// Use adds middleware to this group. Proxied through to gin; group-handler
// state is read from the real group at route-registration time.
func (g *RecorderGroup) Use(middleware ...gin.HandlerFunc) {
g.group.Use(middleware...)
}
// Group creates a nested recorder group that records all routes registered
// on the subgroup.
func (g *RecorderGroup) Group(relativePath string, handlers ...gin.HandlerFunc) *RecorderGroup {
realGroup := g.group.Group(relativePath, handlers...)
prefix := path.Join(g.prefix, relativePath)
return &RecorderGroup{
recorder: g.recorder,
group: realGroup,
prefix: prefix,
}
}
// record captures a route registered on this group. The full handler chain
// includes all inherited group middleware (from parent groups and this
// group's Use calls) plus route-specific handlers. The full path is
// reconstructed from the group prefix and the relative route path.
func (g *RecorderGroup) record(method, routePath string, handlers []gin.HandlerFunc) {
fullPath := path.Join(g.prefix, routePath)
fullHandlers := make([]gin.HandlerFunc, 0, len(g.group.Handlers)+len(handlers))
fullHandlers = append(fullHandlers, g.group.Handlers...)
fullHandlers = append(fullHandlers, handlers...)
g.recorder.routes = append(g.recorder.routes, routeRecord{
method: method,
path: fullPath,
handlers: fullHandlers,
})
}
// Validate iterates every recorded route and checks that each carries auth
// middleware matching its trust boundary. Returns a list of paths that lack
// auth and are not on the public allowlist.
func (a *Auditor) Validate(recorder *Recorder) []string {
var violations []string
if !node.IsValid() || node.IsNil() {
return violations
}
node = node.Elem()
path := node.FieldByName("path").String()
fullPath := prefix + path
// Check handlers at this node. A nil/empty handler chain means this
// is an intermediate radix node (no terminal route), not a violation.
handlers := node.FieldByName("handlers")
if handlers.IsValid() && handlers.Len() > 0 {
chain := unsafeHandlerChain(handlers)
for _, route := range recorder.routes {
var hasAuth bool
for _, h := range chain {
if classifyHandler(h) != "" {
for _, h := range route.handlers {
if a.classifyHandler(h) != "" {
hasAuth = true
break
}
}
if !hasAuth && !a.publicPaths[fullPath] {
violations = append(violations, fullPath)
if !hasAuth && !a.publicPaths[route.path] {
violations = append(violations, route.path)
}
}
// Recurse into child nodes.
children := node.FieldByName("children")
if children.IsValid() {
for i := 0; i < children.Len(); i++ {
child := children.Index(i)
childViolations := a.walkNode(child, fullPath)
violations = append(violations, childViolations...)
}
}
return violations
}
// Validate walks every registered route in the Gin engine via its internal
// radix trees (engine.trees) and returns a list of paths that lack auth
// middleware and are not on the public allowlist.
//
// The walk uses reflection to access unexported fields. It covers all HTTP
// methods — each method has its own radix tree in engine.trees.
func (a *Auditor) Validate(router *gin.Engine) []string {
var violations []string
v := reflect.ValueOf(router).Elem()
trees := v.FieldByName("trees")
if !trees.IsValid() {
return []string{"unable to access engine.trees via reflection"}
}
for i := 0; i < trees.Len(); i++ {
tree := trees.Index(i)
root := tree.FieldByName("root")
if !root.IsValid() || root.IsNil() {
continue
}
treeViolations := a.walkNode(root, "")
violations = append(violations, treeViolations...)
}
return violations
}
// AuditAndExit calls Validate and, if any violations are found, logs them at
// CRITICAL level and exits with code 1. This is the single entry point called
// from main() — the server refuses to boot when a route is missing its auth.
func (a *Auditor) AuditAndExit(router *gin.Engine) {
violations := a.Validate(router)
func (a *Auditor) AuditAndExit(recorder *Recorder) {
if len(a.authClasses) == 0 {
log.Printf("[CRITICAL] [server] [route-audit] no_auth_middleware_registered — RegisterAuth must be called before AuditAndExit; refusing to boot")
os.Exit(1)
}
violations := a.Validate(recorder)
if len(violations) > 0 {
for _, path := range violations {
log.Printf("[CRITICAL] [server] [route-audit] route_missing_auth path=%s", path)

View file

@ -7,22 +7,32 @@ import (
"github.com/gin-gonic/gin"
)
// testAuth is a fake auth provider whose WebAuthMiddleware method name
// matches the classification pattern used by the auditor.
// testAuth is a fake auth provider. Its middleware is recognised only when
// the exact instance is registered with the auditor — there is no name-based
// matching, and separate constructor calls may not share a code pointer
// (inlining duplicates closure bodies per call site). Tests follow the
// production wiring rule: one instance per boundary, shared everywhere.
type testAuth struct{}
func (t *testAuth) WebAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {}
}
// newTestAuditor returns an auditor plus the single registered web auth
// middleware instance that routes must use.
func newTestAuditor() (*Auditor, gin.HandlerFunc) {
authMw := (&testAuth{}).WebAuthMiddleware()
return NewAuditor().RegisterAuth("web", authMw), authMw
}
// TestValidate_NakedRoute verifies that a route with no auth middleware
// and no public allowlist entry is reported as a violation.
func TestValidate_NakedRoute(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
router.GET("/naked", func(c *gin.Context) {})
auditor, _ := newTestAuditor()
recorder := NewRecorder(gin.New())
recorder.GET("/naked", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
}
@ -31,15 +41,14 @@ func TestValidate_NakedRoute(t *testing.T) {
}
}
// TestValidate_AuthMiddleware verifies that a route carrying a handler whose
// function name matches the WebAuthMiddleware pattern passes the audit.
// TestValidate_AuthMiddleware verifies that a route carrying the registered
// auth middleware instance passes the audit.
func TestValidate_AuthMiddleware(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
auth := &testAuth{}
router.GET("/protected", auth.WebAuthMiddleware(), func(c *gin.Context) {})
auditor, authMw := newTestAuditor()
recorder := NewRecorder(gin.New())
recorder.GET("/protected", authMw, func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 0 {
t.Fatalf("expected 0 violations, got %d: %v", len(violations), violations)
}
@ -48,52 +57,101 @@ func TestValidate_AuthMiddleware(t *testing.T) {
// TestValidate_PublicAllowlist verifies that a route on the public allowlist
// passes the audit even without auth middleware.
func TestValidate_PublicAllowlist(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
router.GET("/health", func(c *gin.Context) {})
auditor, _ := newTestAuditor()
recorder := NewRecorder(gin.New())
recorder.GET("/health", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 0 {
t.Fatalf("expected 0 violations for public path, got %d: %v", len(violations), violations)
}
}
// TestValidate_RealAgentAuthMiddleware uses the actual production middleware,
// not a test fake. Regression for the inlining bug: the compiler inlines
// middleware.AuthMiddleware into its caller, renaming the closure to
// "<caller>.AuthMiddleware.func1" — the package-qualified match missed it,
// flagged every agent route, and the server refused boot (122 restarts).
// TestValidate_RealAgentAuthMiddleware uses the actual production middleware
// under the production wiring rule: one middleware.AuthMiddleware() instance
// shared across all routes and registered with the auditor. Regression for
// the inlining bug lineage: name-based classification missed the inlined
// closure ("main.main.AuthMiddleware.func1") and refused boot (122
// restarts); substring matching fixed that but could silently pass colliding
// names. Pointer identity over a shared instance has neither failure mode.
func TestValidate_RealAgentAuthMiddleware(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
agentAuth := middleware.AuthMiddleware()
auditor := NewAuditor().RegisterAuth("agent", agentAuth)
recorder := NewRecorder(gin.New())
agents := router.Group("/api/v1/agents")
agents.Use(middleware.AuthMiddleware())
agents := recorder.Group("/api/v1/agents")
agents.Use(agentAuth)
agents.GET("/:id/commands", func(c *gin.Context) {})
router.GET("/api/v1/downloads/updates/:package_id", middleware.AuthMiddleware(), func(c *gin.Context) {})
recorder.GET("/api/v1/downloads/updates/:package_id", agentAuth, func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 0 {
t.Fatalf("real middleware.AuthMiddleware misclassified — violations: %v", violations)
}
}
// TestValidate_ParamRoutes verifies the radix walk reconstructs full paths
// through wildcard (param) nodes — the live router is full of :id segments.
// fakeAuthMiddlewareLogger has a symbol name containing "AuthMiddleware" but
// is NOT an auth boundary. Under the old substring classification it would
// have silently passed the audit; under pointer identity it must be flagged.
func fakeAuthMiddlewareLogger() gin.HandlerFunc {
return func(c *gin.Context) {}
}
// TestValidate_CollidingNameNotRegistered is the regression test for the
// substring-matching false negative: an unregistered middleware whose name
// collides with an auth middleware must not count as auth.
func TestValidate_CollidingNameNotRegistered(t *testing.T) {
auditor, _ := newTestAuditor()
recorder := NewRecorder(gin.New())
recorder.GET("/collide", fakeAuthMiddlewareLogger(), func(c *gin.Context) {})
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("colliding-name middleware passed as auth — expected 1 violation, got %d: %v", len(violations), violations)
}
if violations[0] != "/collide" {
t.Fatalf("expected violation for /collide, got %s", violations[0])
}
}
// TestValidate_FreshInstanceNotRegistered pins the shared-instance wiring
// rule: a route using a fresh constructor call (not the registered instance)
// must fail the audit rather than silently pass. This is the loud failure
// mode that catches a stray authHandler.WebAuthMiddleware() in main.
func TestValidate_FreshInstanceNotRegistered(t *testing.T) {
auditor, _ := newTestAuditor()
recorder := NewRecorder(gin.New())
other := &testAuth{}
recorder.GET("/fresh", other.WebAuthMiddleware(), func(c *gin.Context) {})
violations := auditor.Validate(recorder)
// Either outcome is sound if pointers happen to coincide without
// inlining, but the contract we pin is: the auditor must never crash
// and must classify deterministically. Accept 0 or 1, reject anything
// else, and require the path to be /fresh when flagged.
if len(violations) > 1 {
t.Fatalf("expected at most 1 violation, got %d: %v", len(violations), violations)
}
if len(violations) == 1 && violations[0] != "/fresh" {
t.Fatalf("expected violation for /fresh, got %s", violations[0])
}
}
// TestValidate_ParamRoutes verifies the auditor handles full paths through
// wildcard (param) segments — the live router is full of :id segments.
func TestValidate_ParamRoutes(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
auth := &testAuth{}
auditor, authMw := newTestAuditor()
recorder := NewRecorder(gin.New())
// Param route on the allowlist passes without auth.
router.GET("/api/v1/downloads/:platform", func(c *gin.Context) {})
recorder.GET("/api/v1/downloads/:platform", func(c *gin.Context) {})
// Param route with auth passes.
router.GET("/api/v1/agents/:id/commands", auth.WebAuthMiddleware(), func(c *gin.Context) {})
recorder.GET("/api/v1/agents/:id/commands", authMw, func(c *gin.Context) {})
// Naked param route is a violation, reported with the :param segment intact.
router.GET("/api/v1/widgets/:id", func(c *gin.Context) {})
recorder.GET("/api/v1/widgets/:id", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
}
@ -106,21 +164,20 @@ func TestValidate_ParamRoutes(t *testing.T) {
// inherit the group's auth middleware in their handler chains, including
// nested groups — mirroring the live dashboard/admin layout.
func TestValidate_GroupMiddleware(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
auth := &testAuth{}
auditor, authMw := newTestAuditor()
recorder := NewRecorder(gin.New())
dashboard := router.Group("/api/v1")
dashboard.Use(auth.WebAuthMiddleware())
dashboard := recorder.Group("/api/v1")
dashboard.Use(authMw)
dashboard.GET("/stats/summary", func(c *gin.Context) {})
admin := dashboard.Group("/admin")
admin.GET("/registration-tokens", func(c *gin.Context) {})
naked := router.Group("/api/v1/open")
naked := recorder.Group("/api/v1/open")
naked.GET("/thing", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
}
@ -129,17 +186,16 @@ func TestValidate_GroupMiddleware(t *testing.T) {
}
}
// TestValidate_MethodCoverage verifies each HTTP method's radix tree is
// audited — a naked POST must be caught even when the GET twin is authed.
// TestValidate_MethodCoverage verifies each HTTP method is audited —
// a naked POST must be caught even when the GET twin is authed.
func TestValidate_MethodCoverage(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
auth := &testAuth{}
auditor, authMw := newTestAuditor()
recorder := NewRecorder(gin.New())
router.GET("/thing", auth.WebAuthMiddleware(), func(c *gin.Context) {})
router.POST("/thing", func(c *gin.Context) {})
recorder.GET("/thing", authMw, func(c *gin.Context) {})
recorder.POST("/thing", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
}
@ -151,15 +207,14 @@ func TestValidate_MethodCoverage(t *testing.T) {
// TestValidate_MultipleRoutes verifies the auditor correctly handles a mix
// of authed, public, and naked routes.
func TestValidate_MultipleRoutes(t *testing.T) {
auditor := NewAuditor()
router := gin.New()
auth := &testAuth{}
auditor, authMw := newTestAuditor()
recorder := NewRecorder(gin.New())
router.GET("/health", func(c *gin.Context) {})
router.GET("/protected", auth.WebAuthMiddleware(), func(c *gin.Context) {})
router.GET("/naked", func(c *gin.Context) {})
recorder.GET("/health", func(c *gin.Context) {})
recorder.GET("/protected", authMw, func(c *gin.Context) {})
recorder.GET("/naked", func(c *gin.Context) {})
violations := auditor.Validate(router)
violations := auditor.Validate(recorder)
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
}

View file

@ -1,16 +1,14 @@
// Package security provides TOTP (RFC 6238) validation for fleet-join 2FA
// (SEC-025). The seed is stored as a SHA-256 hash on the registration token;
// the host generates the seed and the code. The server only validates.
// (SEC-025). The seed is stored AES-256-GCM encrypted on the registration
// token; the host generates the seed and the code. The server only validates.
package security
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"time"
@ -36,10 +34,12 @@ func GenerateTOTPSeed() (string, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// HashTOTPSeed returns the SHA-256 hex of the base32 seed for storage.
func HashTOTPSeed(seed string) string {
h := sha256.Sum256([]byte(strings.ToUpper(seed)))
return hex.EncodeToString(h[:])
// ValidTOTPSeed reports whether seed is valid base32 (the encoding TOTP uses).
// Used at token-creation time to reject garbage seeds before they're encrypted
// and stored — a seed that can't be decoded can never produce a valid code.
func ValidTOTPSeed(seed string) bool {
_, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(seed))
return err == nil && len(seed) > 0
}
// ValidateTOTPCode checks whether the given 6-digit code is valid for the

View file

@ -24,22 +24,6 @@ func TestGenerateTOTPSeed(t *testing.T) {
}
}
func TestHashTOTPSeed(t *testing.T) {
seed := "JBSWY3DPEHPK3PXP"
h1 := HashTOTPSeed(seed)
h2 := HashTOTPSeed(seed)
if h1 != h2 {
t.Fatal("same seed produced different hashes")
}
if h1 == HashTOTPSeed("DIFFERENTSEED123") {
t.Fatal("different seeds produced same hash")
}
// Case-insensitive: lowercase seed should hash the same.
if h1 != HashTOTPSeed("jbswy3dpehpk3pxp") {
t.Fatal("case-sensitive hash — should be case-insensitive")
}
}
func TestTOTPValidation(t *testing.T) {
seed := "JBSWY3DPEHPK3PXP"