Skip to content

Commit bc0f7c7

Browse files
authored
fix(security): Enforce fail-closed for empty admin token (#425) (#691)
1 parent 02662b5 commit bc0f7c7

8 files changed

Lines changed: 198 additions & 20 deletions

File tree

control-plane/internal/config/config.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,12 @@ type AuthorizationConfig struct {
307307
// DefaultApprovalDurationHours is the default duration for permission approvals
308308
DefaultApprovalDurationHours int `yaml:"default_approval_duration_hours" mapstructure:"default_approval_duration_hours" default:"720"`
309309
// AdminToken is a separate token required for admin operations (tag approval,
310-
// policy management). If empty, admin routes fall back to the standard API key.
310+
// policy management). When empty and InsecureDisableAdminAuth is false, admin
311+
// routes will reject requests at startup or return 401.
311312
AdminToken string `yaml:"admin_token" mapstructure:"admin_token"`
313+
// InsecureDisableAdminAuth explicitly permits running admin routes without
314+
// an admin token. This should only be enabled for trusted local development.
315+
InsecureDisableAdminAuth bool `yaml:"insecure_disable_admin_auth" mapstructure:"insecure_disable_admin_auth"`
312316
// InternalToken is sent as Authorization: Bearer header when the control plane
313317
// forwards execution requests to agents. Agents with RequireOriginAuth enabled
314318
// validate this token, preventing direct access to their HTTP ports.
@@ -658,6 +662,13 @@ func ApplyEnvOverrides(cfg *Config) {
658662
if val := os.Getenv("AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN"); val != "" {
659663
cfg.Features.DID.Authorization.AdminToken = val
660664
}
665+
if val, ok := os.LookupEnv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN"); ok {
666+
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = parseEnvBool(val)
667+
}
668+
// Also support the nested path format for consistency.
669+
if val, ok := os.LookupEnv("AGENTFIELD_AUTHORIZATION_INSECURE_DISABLE_ADMIN_AUTH"); ok {
670+
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = parseEnvBool(val)
671+
}
661672
if val := os.Getenv("AGENTFIELD_AUTHORIZATION_INTERNAL_TOKEN"); val != "" {
662673
cfg.Features.DID.Authorization.InternalToken = val
663674
}

control-plane/internal/config/config_additional_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ func TestApplyEnvOverrides(t *testing.T) {
295295
"AGENTFIELD_AUTHORIZATION_DID_AUTH_ENABLED": "1",
296296
"AGENTFIELD_AUTHORIZATION_DOMAIN": "auth.local",
297297
"AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN": "admin-token",
298+
"AGENTFIELD_INSECURE_ADMIN_NO_TOKEN": "true",
298299
"AGENTFIELD_AUTHORIZATION_INTERNAL_TOKEN": "internal-token",
299300
"AGENTFIELD_AUTHORIZATION_DEFAULT_DENY": "true",
300301
"AGENTFIELD_NODE_LOG_PROXY_CONNECT_TIMEOUT": "21s",
@@ -379,6 +380,7 @@ func TestApplyEnvOverrides(t *testing.T) {
379380
!cfg.Features.DID.Authorization.DIDAuthEnabled ||
380381
cfg.Features.DID.Authorization.Domain != "auth.local" ||
381382
cfg.Features.DID.Authorization.AdminToken != "admin-token" ||
383+
!cfg.Features.DID.Authorization.InsecureDisableAdminAuth ||
382384
cfg.Features.DID.Authorization.InternalToken != "internal-token" ||
383385
!cfg.Features.DID.Authorization.DefaultDeny {
384386
t.Fatalf("unexpected authorization overrides: %+v", cfg.Features.DID.Authorization)
@@ -444,6 +446,31 @@ func TestApplyEnvOverridesAPIAuthInsecureDisable(t *testing.T) {
444446
})
445447
}
446448

449+
func TestApplyEnvOverridesInsecureAdminNoToken(t *testing.T) {
450+
t.Run("short environment name", func(t *testing.T) {
451+
cfg := &Config{}
452+
t.Setenv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN", "true")
453+
454+
ApplyEnvOverrides(cfg)
455+
456+
if !cfg.Features.DID.Authorization.InsecureDisableAdminAuth {
457+
t.Fatal("expected insecure admin auth disable from environment")
458+
}
459+
})
460+
461+
t.Run("nested environment name takes precedence", func(t *testing.T) {
462+
cfg := &Config{}
463+
t.Setenv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN", "true")
464+
t.Setenv("AGENTFIELD_AUTHORIZATION_INSECURE_DISABLE_ADMIN_AUTH", "false")
465+
466+
ApplyEnvOverrides(cfg)
467+
468+
if cfg.Features.DID.Authorization.InsecureDisableAdminAuth {
469+
t.Fatal("expected nested insecure admin auth setting to take precedence")
470+
}
471+
})
472+
}
473+
447474
func TestApplyEnvOverridesIgnoresInvalidValues(t *testing.T) {
448475
cfg := &Config{
449476
AgentField: AgentFieldConfig{

control-plane/internal/server/middleware/auth.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,44 @@ func queryAPIKeyAllowed(c *gin.Context, allowedPaths map[string]struct{}) bool {
173173
return false
174174
}
175175

176+
// AdminAuthConfig mirrors server configuration for admin token authentication.
177+
type AdminAuthConfig struct {
178+
AdminToken string
179+
InsecureDisableAdminAuth bool
180+
}
181+
182+
// ValidateAdminTokenAuth rejects an implicit unauthenticated admin configuration.
183+
func ValidateAdminTokenAuth(config AdminAuthConfig) error {
184+
if config.AdminToken == "" && !config.InsecureDisableAdminAuth {
185+
return errors.New("admin token is required when authorization is enabled; set AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN or explicitly set AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
186+
}
187+
return nil
188+
}
189+
176190
// AdminTokenAuth enforces a separate admin token for admin routes.
177-
// If adminToken is empty, the middleware is a no-op (falls back to global API key auth).
178191
// Admin tokens must be sent via the X-Admin-Token header only (not Bearer) to avoid
179192
// collision with the API key Bearer token namespace.
180-
func AdminTokenAuth(adminToken string) gin.HandlerFunc {
193+
func AdminTokenAuth(config AdminAuthConfig) gin.HandlerFunc {
181194
return func(c *gin.Context) {
182-
if adminToken == "" {
195+
// Unauthenticated admin operation must be explicitly enabled at startup.
196+
if config.AdminToken == "" && config.InsecureDisableAdminAuth {
183197
c.Next()
184198
return
185199
}
186200

201+
// Fail-closed: if the admin token is not configured and insecure mode
202+
// was not explicitly enabled, reject all requests.
203+
if config.AdminToken == "" {
204+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
205+
"error": "unauthorized",
206+
"message": "admin authentication required but admin token is not configured on the server",
207+
})
208+
return
209+
}
210+
187211
token := c.GetHeader("X-Admin-Token")
188212

189-
if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) != 1 {
213+
if subtle.ConstantTimeCompare([]byte(token), []byte(config.AdminToken)) != 1 {
190214
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
191215
"error": "forbidden",
192216
"message": "admin token required for this operation (use X-Admin-Token header)",
Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package middleware
22

33
import (
4+
"encoding/json"
45
"net/http"
56
"net/http/httptest"
67
"testing"
@@ -9,42 +10,67 @@ import (
910
"github.com/stretchr/testify/require"
1011
)
1112

13+
func TestValidateAdminTokenAuth_RejectsImplicitDisable(t *testing.T) {
14+
err := ValidateAdminTokenAuth(AdminAuthConfig{})
15+
require.Error(t, err)
16+
require.Contains(t, err.Error(), "AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
17+
}
18+
19+
func TestValidateAdminTokenAuth_AcceptsExplicitInsecure(t *testing.T) {
20+
err := ValidateAdminTokenAuth(AdminAuthConfig{InsecureDisableAdminAuth: true})
21+
require.NoError(t, err)
22+
}
23+
24+
func TestValidateAdminTokenAuth_AcceptsConfiguredToken(t *testing.T) {
25+
err := ValidateAdminTokenAuth(AdminAuthConfig{AdminToken: "admin-secret"})
26+
require.NoError(t, err)
27+
}
28+
1229
func TestAdminTokenAuth(t *testing.T) {
1330
tests := []struct {
1431
name string
15-
adminToken string
32+
config AdminAuthConfig
1633
headerToken string
1734
wantStatus int
35+
wantBody string
1836
}{
1937
{
20-
name: "disabled allows request through",
21-
adminToken: "",
38+
name: "empty token with explicit insecure allows request through",
39+
config: AdminAuthConfig{InsecureDisableAdminAuth: true},
2240
wantStatus: http.StatusOK,
2341
},
42+
{
43+
name: "empty token without insecure flag returns 401",
44+
config: AdminAuthConfig{},
45+
wantStatus: http.StatusUnauthorized,
46+
wantBody: "admin authentication required but admin token is not configured",
47+
},
2448
{
2549
name: "valid admin token",
26-
adminToken: "admin-secret",
50+
config: AdminAuthConfig{AdminToken: "admin-secret"},
2751
headerToken: "admin-secret",
2852
wantStatus: http.StatusOK,
2953
},
3054
{
31-
name: "missing admin token header",
32-
adminToken: "admin-secret",
33-
wantStatus: http.StatusForbidden,
55+
name: "missing admin token header",
56+
config: AdminAuthConfig{AdminToken: "admin-secret"},
57+
wantStatus: http.StatusForbidden,
58+
wantBody: "admin token required",
3459
},
3560
{
3661
name: "invalid admin token header",
37-
adminToken: "admin-secret",
62+
config: AdminAuthConfig{AdminToken: "admin-secret"},
3863
headerToken: "wrong-secret",
3964
wantStatus: http.StatusForbidden,
65+
wantBody: "admin token required",
4066
},
4167
}
4268

4369
for _, tt := range tests {
4470
t.Run(tt.name, func(t *testing.T) {
4571
gin.SetMode(gin.TestMode)
4672
router := gin.New()
47-
router.Use(AdminTokenAuth(tt.adminToken))
73+
router.Use(AdminTokenAuth(tt.config))
4874
router.GET("/admin", func(c *gin.Context) {
4975
c.String(http.StatusOK, "ok")
5076
})
@@ -58,9 +84,49 @@ func TestAdminTokenAuth(t *testing.T) {
5884
router.ServeHTTP(recorder, req)
5985

6086
require.Equal(t, tt.wantStatus, recorder.Code)
61-
if tt.wantStatus == http.StatusForbidden {
62-
require.Contains(t, recorder.Body.String(), "admin token required")
87+
if tt.wantBody != "" {
88+
require.Contains(t, recorder.Body.String(), tt.wantBody)
6389
}
6490
})
6591
}
6692
}
93+
94+
func TestAdminTokenAuth_EmptyTokenFailsClosed(t *testing.T) {
95+
gin.SetMode(gin.TestMode)
96+
router := gin.New()
97+
router.Use(AdminTokenAuth(AdminAuthConfig{}))
98+
router.GET("/admin/tags", func(c *gin.Context) {
99+
c.String(http.StatusOK, "ok")
100+
})
101+
102+
// Request with only a valid API key (simulated via no admin token header)
103+
// should be rejected because admin token is not configured.
104+
req := httptest.NewRequest(http.MethodGet, "/admin/tags", nil)
105+
recorder := httptest.NewRecorder()
106+
router.ServeHTTP(recorder, req)
107+
108+
require.Equal(t, http.StatusUnauthorized, recorder.Code)
109+
110+
var resp map[string]string
111+
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
112+
require.Equal(t, "unauthorized", resp["error"])
113+
require.Contains(t, resp["message"], "admin token is not configured")
114+
}
115+
116+
func TestAdminTokenAuth_APIKeyOnlyReturns401WhenAdminTokenConfigured(t *testing.T) {
117+
gin.SetMode(gin.TestMode)
118+
router := gin.New()
119+
router.Use(AdminTokenAuth(AdminAuthConfig{AdminToken: "admin-secret"}))
120+
router.GET("/admin/policies", func(c *gin.Context) {
121+
c.String(http.StatusOK, "ok")
122+
})
123+
124+
// Request with API key header but NO admin token — should return 403
125+
req := httptest.NewRequest(http.MethodGet, "/admin/policies", nil)
126+
req.Header.Set("X-API-Key", "some-api-key")
127+
recorder := httptest.NewRecorder()
128+
router.ServeHTTP(recorder, req)
129+
130+
require.Equal(t, http.StatusForbidden, recorder.Code)
131+
require.Contains(t, recorder.Body.String(), "admin token required")
132+
}

control-plane/internal/server/routes_admin.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ func (s *AgentFieldServer) registerAdminRoutes(agentAPI *gin.RouterGroup) {
1717
// Admin routes for tag approval and access policy management (VC-based authorization)
1818
if s.config.Features.DID.Authorization.Enabled {
1919
adminGroup := agentAPI.Group("")
20-
adminGroup.Use(middleware.AdminTokenAuth(s.config.Features.DID.Authorization.AdminToken))
20+
adminGroup.Use(middleware.AdminTokenAuth(middleware.AdminAuthConfig{
21+
AdminToken: s.config.Features.DID.Authorization.AdminToken,
22+
InsecureDisableAdminAuth: s.config.Features.DID.Authorization.InsecureDisableAdminAuth,
23+
}))
2124

2225
// Tag approval admin routes
2326
if s.tagApprovalService != nil {

control-plane/internal/server/server.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ func NewAgentFieldServer(cfg *config.Config) (*AgentFieldServer, error) {
104104
return nil, fmt.Errorf("invalid API authentication configuration: %w", err)
105105
}
106106

107+
if cfg.Features.DID.Authorization.Enabled {
108+
if err := validateAdminAuthConfig(cfg.Features.DID.Authorization); err != nil {
109+
return nil, fmt.Errorf("invalid admin authentication configuration: %w", err)
110+
}
111+
}
112+
107113
// Define agentfieldHome at the very top
108114
agentfieldHome := os.Getenv("AGENTFIELD_HOME")
109115
if agentfieldHome == "" {
@@ -285,9 +291,6 @@ func NewAgentFieldServer(cfg *config.Config) (*AgentFieldServer, error) {
285291
didWebService = services.NewDIDWebService(domain, didService, storageProvider)
286292

287293
if cfg.Features.DID.Authorization.Enabled {
288-
if cfg.Features.DID.Authorization.AdminToken == "" {
289-
logger.Logger.Error().Msg("⚠️ SECURITY WARNING: Authorization is enabled but no admin_token is configured! Admin routes (tag approval, policy management) are unprotected. Set AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN for production use.")
290-
}
291294
if cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode == "" || cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode == "auto" {
292295
logger.Logger.Warn().Msg("⚠️ Tag approval default_mode is 'auto' — all agent tags will be auto-approved. Set tag_approval_rules.default_mode to 'manual' for production.")
293296
}
@@ -550,6 +553,22 @@ func validateAPIAuthConfig(auth config.AuthConfig) error {
550553
return nil
551554
}
552555

556+
func validateAdminAuthConfig(authz config.AuthorizationConfig) error {
557+
adminConfig := middleware.AdminAuthConfig{
558+
AdminToken: authz.AdminToken,
559+
InsecureDisableAdminAuth: authz.InsecureDisableAdminAuth,
560+
}
561+
if err := middleware.ValidateAdminTokenAuth(adminConfig); err != nil {
562+
return err
563+
}
564+
if authz.AdminToken == "" {
565+
logger.Logger.Warn().
566+
Bool("insecure_disable_admin_auth", true).
567+
Msg("SECURITY WARNING: Admin token authentication is explicitly disabled; admin routes (tag approval, policy management) are unauthenticated")
568+
}
569+
return nil
570+
}
571+
553572
// configReloadFn returns a function that reloads config from the database,
554573
// or nil if AGENTFIELD_CONFIG_SOURCE is not set to "db".
555574
// The returned function acquires configMu to prevent data races with

control-plane/internal/server/server_additional_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func TestNewAgentFieldServer(t *testing.T) {
139139
cfg.Features.DID.Authorization.DIDAuthEnabled = true
140140
cfg.Features.DID.Authorization.Domain = "example.com"
141141
cfg.Features.DID.Authorization.InternalToken = "internal-token"
142+
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
142143
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "manual"
143144
cfg.Features.Connector.Enabled = false
144145

@@ -198,6 +199,31 @@ func TestValidateAPIAuthConfigWarnsWhenExplicitlyDisabled(t *testing.T) {
198199
require.Contains(t, output.String(), "API key authentication is explicitly disabled")
199200
}
200201

202+
func TestValidateAdminAuthConfigWarnsWhenExplicitlyDisabled(t *testing.T) {
203+
previousLogger := logger.Logger
204+
var output bytes.Buffer
205+
logger.Logger = zerolog.New(&output)
206+
t.Cleanup(func() { logger.Logger = previousLogger })
207+
208+
err := validateAdminAuthConfig(config.AuthorizationConfig{InsecureDisableAdminAuth: true})
209+
require.NoError(t, err)
210+
require.Contains(t, output.String(), `"level":"warn"`)
211+
require.Contains(t, output.String(), "Admin token authentication is explicitly disabled")
212+
}
213+
214+
func TestNewAgentFieldServerRejectsEmptyAdminToken(t *testing.T) {
215+
cfg := baseConfigForDBTests()
216+
cfg.API.Auth.APIKey = ""
217+
cfg.API.Auth.InsecureDisableAuth = true
218+
cfg.Features.DID.Enabled = false
219+
cfg.Features.DID.Authorization.Enabled = true
220+
cfg.Features.DID.Authorization.AdminToken = ""
221+
222+
srv, err := NewAgentFieldServer(&cfg)
223+
require.Nil(t, srv)
224+
require.ErrorContains(t, err, "AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
225+
}
226+
201227
func TestStartAndStop(t *testing.T) {
202228
cfg := baseConfigForDBTests()
203229
cfg.UI.Enabled = false
@@ -360,6 +386,7 @@ func TestSetupRoutesWithDIDServices(t *testing.T) {
360386
cfg.Features.DID.Authorization.DIDAuthEnabled = true
361387
cfg.Features.DID.Authorization.Domain = "example.com"
362388
cfg.Features.DID.Authorization.InternalToken = "internal-token"
389+
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
363390
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "manual"
364391
cfg.Features.Connector.Enabled = false
365392

control-plane/internal/server/server_coverage_additional_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func TestNewAgentFieldServerCoversFallbacksAndOptionalServices(t *testing.T) {
4545
cfg.Features.DID.Enabled = true
4646
cfg.Features.DID.KeyAlgorithm = "Ed25519"
4747
cfg.Features.DID.Authorization.Enabled = true
48+
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
4849
cfg.Features.DID.Authorization.Domain = ""
4950
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "auto"
5051
cfg.Features.DID.Authorization.AccessPolicies = []config.AccessPolicyConfig{

0 commit comments

Comments
 (0)