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:
parent
7c25204bd8
commit
a4d585c79c
14 changed files with 1109 additions and 401 deletions
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue