Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/daemon/pkg/toolbox/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
72 changes: 72 additions & 0 deletions apps/daemon/pkg/toolbox/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions apps/daemon/pkg/toolbox/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading