diff --git a/apps/daemon/pkg/toolbox/auth.go b/apps/daemon/pkg/toolbox/auth.go new file mode 100644 index 000000000..3a16983fd --- /dev/null +++ b/apps/daemon/pkg/toolbox/auth.go @@ -0,0 +1,61 @@ +// Copyright 2025 BoxLite AI (originally Daytona Platforms Inc. +// Modified by BoxLite AI, 2025-2026 +// SPDX-License-Identifier: AGPL-3.0 + +package toolbox + +import ( + "crypto/subtle" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +// toolboxAuthExemptPaths are reachable without the box auth token: /init sets the +// token, /version is a harmless liveness probe. +var toolboxAuthExemptPaths = map[string]bool{ + "/init": true, + "/version": true, +} + +// toolboxAuthMiddleware enforces the box auth token (set via /init, previously +// only used as a telemetry attribute) as a Bearer credential on toolbox requests +// when TOOLBOX_REQUIRE_AUTH=true. +// +// It defaults to OFF so the existing runner→proxy→daemon path keeps working: +// turning it on requires the upstream proxy to forward the token on every +// request, which is a separate, coordinated change (see PR description). When +// enabled, requests to non-exempt paths are rejected unless they carry the exact +// token, and requests that arrive before /init (token unset) are rejected too — +// fail closed. +func (s *server) toolboxAuthMiddleware() gin.HandlerFunc { + return s.toolboxAuthMiddlewareMode(os.Getenv("TOOLBOX_REQUIRE_AUTH") == "true") +} + +func (s *server) toolboxAuthMiddlewareMode(required bool) gin.HandlerFunc { + return func(ctx *gin.Context) { + if !required || toolboxAuthExemptPaths[ctx.Request.URL.Path] { + ctx.Next() + return + } + if s.authToken == "" || !bearerTokenMatches(ctx.GetHeader("Authorization"), s.authToken) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + ctx.Next() + } +} + +// bearerTokenMatches reports whether the Authorization header carries exactly the +// expected bearer token, using a constant-time compare to avoid leaking it via +// timing. +func bearerTokenMatches(authHeader, expected string) bool { + const prefix = "Bearer " + if !strings.HasPrefix(authHeader, prefix) { + return false + } + got := strings.TrimPrefix(authHeader, prefix) + return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1 +} diff --git a/apps/daemon/pkg/toolbox/auth_test.go b/apps/daemon/pkg/toolbox/auth_test.go new file mode 100644 index 000000000..215603153 --- /dev/null +++ b/apps/daemon/pkg/toolbox/auth_test.go @@ -0,0 +1,72 @@ +package toolbox + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { gin.SetMode(gin.TestMode) } + +func newAuthTestEngine(t *testing.T, required bool, token string) *gin.Engine { + t.Helper() + s := &server{authToken: token} + r := gin.New() + r.Use(s.toolboxAuthMiddlewareMode(required)) + r.POST("/init", func(c *gin.Context) { c.Status(http.StatusOK) }) // exempt + r.POST("/process/execute", func(c *gin.Context) { c.Status(http.StatusOK) }) + return r +} + +func do(t *testing.T, r *gin.Engine, method, path, auth string) int { + t.Helper() + req := httptest.NewRequest(method, path, nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr.Code +} + +// TestToolboxAuth_EnforcesTokenWhenRequired is the security regression: with +// enforcement on, a sensitive route (process execute → RCE) must reject missing +// or wrong tokens, and accept only the exact token. Before the fix the toolbox +// had no auth at all, so any reachable caller got unauthenticated RCE. +func TestToolboxAuth_EnforcesTokenWhenRequired(t *testing.T) { + r := newAuthTestEngine(t, true, "secret-token") + + if code := do(t, r, http.MethodPost, "/process/execute", ""); code != http.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401", code) + } + if code := do(t, r, http.MethodPost, "/process/execute", "Bearer wrong"); code != http.StatusUnauthorized { + t.Errorf("wrong token: status = %d, want 401", code) + } + if code := do(t, r, http.MethodPost, "/process/execute", "Bearer secret-token"); code != http.StatusOK { + t.Errorf("correct token: status = %d, want 200", code) + } + // /init must stay reachable without a token (it is what sets the token). + if code := do(t, r, http.MethodPost, "/init", ""); code != http.StatusOK { + t.Errorf("/init should be exempt: status = %d, want 200", code) + } +} + +// TestToolboxAuth_EnforcesWhenTokenUnset: enforcement on but token not yet set +// (request before /init) must fail closed. +func TestToolboxAuth_EnforcesWhenTokenUnset(t *testing.T) { + r := newAuthTestEngine(t, true, "") + if code := do(t, r, http.MethodPost, "/process/execute", "Bearer anything"); code != http.StatusUnauthorized { + t.Errorf("unset token: status = %d, want 401 (fail closed)", code) + } +} + +// TestToolboxAuth_DisabledByDefault documents that with enforcement off (the +// default) the existing path is unaffected. +func TestToolboxAuth_DisabledByDefault(t *testing.T) { + r := newAuthTestEngine(t, false, "secret-token") + if code := do(t, r, http.MethodPost, "/process/execute", ""); code != http.StatusOK { + t.Errorf("disabled: status = %d, want 200 (pass-through)", code) + } +} diff --git a/apps/daemon/pkg/toolbox/server.go b/apps/daemon/pkg/toolbox/server.go index 9624f3dcc..dc4376e88 100644 --- a/apps/daemon/pkg/toolbox/server.go +++ b/apps/daemon/pkg/toolbox/server.go @@ -148,6 +148,11 @@ func (s *server) Start() error { noTelemetryRouter.Use(sloggin.New(s.logger)) r.Use(errMiddleware) noTelemetryRouter.Use(errMiddleware) + // Enforce the box auth token when TOOLBOX_REQUIRE_AUTH=true (off by default; + // see toolboxAuthMiddleware). Applied to both routers so the /proxy group on + // noTelemetryRouter is covered. + r.Use(s.toolboxAuthMiddleware()) + noTelemetryRouter.Use(s.toolboxAuthMiddleware()) binding.Validator = new(DefaultValidator) // Add swagger UI in development mode