Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-11 04:20:08 -04:00
commit c33b62489e
3 changed files with 367 additions and 0 deletions

View 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")
}

View 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])
}
}