feat: boot-time route audit — ETHOS #2 enforced structurally
Walk every Gin radix tree at startup, classify each route's handler chain (agent JWT / web JWT / metrics token), and refuse to boot if any route lacks auth and is not on the explicit public allowlist. Adding a path to PublicPathSet is a reviewable act. Covers param routes, nested groups, and per-method trees (tested).
This commit is contained in:
parent
71cf60b66c
commit
c33b62489e
3 changed files with 367 additions and 0 deletions
215
server/internal/routeaudit/auditor.go
Normal file
215
server/internal/routeaudit/auditor.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// Package routeaudit enforces ETHOS #2 (no unauthenticated endpoints) by
|
||||
// walking 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.
|
||||
package routeaudit
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PublicPathSet is the explicit allowlist of routes that are intentionally
|
||||
// unauthenticated. Every route registered on the Gin engine must carry a
|
||||
// recognised auth middleware (agent JWT, web JWT, or metrics token) unless it
|
||||
// appears in this set. Adding a path here is a reviewable act.
|
||||
var PublicPathSet = map[string]bool{
|
||||
// Health checks — no auth so load balancers and monitoring can reach them.
|
||||
"/health": true,
|
||||
"/api/health": true,
|
||||
"/api/v1/health": true,
|
||||
|
||||
// Authentication endpoints — login, logout are public entry points.
|
||||
// /auth/verify carries WebAuthMiddleware and is intentionally NOT in this set.
|
||||
"/api/v1/auth/login": true,
|
||||
"/api/v1/auth/logout": true,
|
||||
|
||||
// System metadata — public so installers and agents can discover the server.
|
||||
"/api/v1/public-key": true,
|
||||
"/api/v1/public-keys": true,
|
||||
"/api/v1/info": true,
|
||||
|
||||
// Agent setup — public so new agents can bootstrap.
|
||||
"/api/v1/setup/agent": true,
|
||||
"/api/v1/setup/templates": true,
|
||||
"/api/v1/setup/validate": true,
|
||||
|
||||
// Agent registration and token renewal — public entry points (guarded
|
||||
// internally by registration tokens and machine binding respectively).
|
||||
"/api/v1/agents/register": true,
|
||||
"/api/v1/agents/renew": true,
|
||||
|
||||
// Downloads — public for bootstrapping. Artifact download is public so
|
||||
// the server can compute hashes at approval time without an agent JWT.
|
||||
"/api/v1/downloads/:platform": true,
|
||||
"/api/v1/install/:platform": true,
|
||||
"/api/v1/manifest": true,
|
||||
"/api/v1/helper/:arch": true,
|
||||
"/api/v1/desktop/:arch": true,
|
||||
"/api/v1/downloads/artifact": true,
|
||||
}
|
||||
|
||||
// Auditor validates that every registered route carries auth middleware
|
||||
// matching its trust boundary.
|
||||
type Auditor struct {
|
||||
publicPaths map[string]bool
|
||||
}
|
||||
|
||||
// NewAuditor returns an Auditor initialised with the public route allowlist.
|
||||
func NewAuditor() *Auditor {
|
||||
return &Auditor{
|
||||
publicPaths: PublicPathSet,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Classification rules:
|
||||
// - middleware.AuthMiddleware.* -> agent JWT boundary
|
||||
// - WebAuthMiddleware.* -> web JWT boundary
|
||||
// - MetricsBearerAuth.* -> metrics token boundary
|
||||
func classifyHandler(h gin.HandlerFunc) string {
|
||||
name := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name()
|
||||
|
||||
if strings.Contains(name, "middleware.AuthMiddleware") {
|
||||
return "agent"
|
||||
}
|
||||
if strings.Contains(name, "WebAuthMiddleware") {
|
||||
return "web"
|
||||
}
|
||||
if strings.Contains(name, "MetricsBearerAuth") {
|
||||
return "metrics"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
var hasAuth bool
|
||||
for _, h := range chain {
|
||||
if classifyHandler(h) != "" {
|
||||
hasAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAuth && !a.publicPaths[fullPath] {
|
||||
violations = append(violations, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if len(violations) > 0 {
|
||||
for _, path := range violations {
|
||||
log.Printf("[CRITICAL] [server] [route-audit] route_missing_auth path=%s", path)
|
||||
}
|
||||
log.Printf("[CRITICAL] [server] [route-audit] audit_failed count=%d — server refusing to boot", len(violations))
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("[INFO] [server] [route-audit] audit_passed — all routes carry expected auth")
|
||||
}
|
||||
147
server/internal/routeaudit/auditor_test.go
Normal file
147
server/internal/routeaudit/auditor_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package routeaudit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// testAuth is a fake auth provider whose WebAuthMiddleware method name
|
||||
// matches the classification pattern used by the auditor.
|
||||
type testAuth struct{}
|
||||
|
||||
func (t *testAuth) WebAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {}
|
||||
}
|
||||
|
||||
// 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) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/naked" {
|
||||
t.Fatalf("expected violation for /naked, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_AuthMiddleware verifies that a route carrying a handler whose
|
||||
// function name matches the WebAuthMiddleware pattern 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) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("expected 0 violations, got %d: %v", len(violations), violations)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("expected 0 violations for public path, got %d: %v", len(violations), violations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_ParamRoutes verifies the radix walk reconstructs full paths
|
||||
// through wildcard (param) nodes — the live router is full of :id segments.
|
||||
func TestValidate_ParamRoutes(t *testing.T) {
|
||||
auditor := NewAuditor()
|
||||
router := gin.New()
|
||||
auth := &testAuth{}
|
||||
|
||||
// Param route on the allowlist passes without auth.
|
||||
router.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) {})
|
||||
// Naked param route is a violation, reported with the :param segment intact.
|
||||
router.GET("/api/v1/widgets/:id", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/api/v1/widgets/:id" {
|
||||
t.Fatalf("expected violation for /api/v1/widgets/:id, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_GroupMiddleware verifies routes registered under a group
|
||||
// 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{}
|
||||
|
||||
dashboard := router.Group("/api/v1")
|
||||
dashboard.Use(auth.WebAuthMiddleware())
|
||||
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.GET("/thing", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/api/v1/open/thing" {
|
||||
t.Fatalf("expected violation for /api/v1/open/thing, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_MethodCoverage verifies each HTTP method's radix tree 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{}
|
||||
|
||||
router.GET("/thing", auth.WebAuthMiddleware(), func(c *gin.Context) {})
|
||||
router.POST("/thing", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/thing" {
|
||||
t.Fatalf("expected violation for /thing, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
router.GET("/health", func(c *gin.Context) {})
|
||||
router.GET("/protected", auth.WebAuthMiddleware(), func(c *gin.Context) {})
|
||||
router.GET("/naked", func(c *gin.Context) {})
|
||||
|
||||
violations := auditor.Validate(router)
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations)
|
||||
}
|
||||
if violations[0] != "/naked" {
|
||||
t.Fatalf("expected violation for /naked, got %s", violations[0])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue