Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ func run() error {
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(mw.InjectLogger(logger)) // Inject logger for debug logging in JwtAuth
r.Use(mw.JwtAuth(app.Config.JwtSecret))
r.Mount("/", app.Registry.Handler())
})
Expand Down
26 changes: 17 additions & 9 deletions lib/builds/builder_agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,19 +516,27 @@ func setupRegistryAuth(registryURL, token string) error {
return fmt.Errorf("marshal docker config: %w", err)
}

// Ensure ~/.docker directory exists
dockerDir := "/home/builder/.docker"
if err := os.MkdirAll(dockerDir, 0700); err != nil {
return fmt.Errorf("create docker config dir: %w", err)
// Write config to multiple locations to ensure BuildKit finds it
// buildctl-daemonless.sh may run buildkitd with different user/env
configDirs := []string{
"/home/builder/.docker", // Builder user home
"/root/.docker", // Root user (buildkitd may run as root)
}

// Write config.json
configPath := filepath.Join(dockerDir, "config.json")
if err := os.WriteFile(configPath, configData, 0600); err != nil {
return fmt.Errorf("write docker config: %w", err)
for _, dockerDir := range configDirs {
if err := os.MkdirAll(dockerDir, 0700); err != nil {
log.Printf("Warning: failed to create %s: %v", dockerDir, err)
continue
}

configPath := filepath.Join(dockerDir, "config.json")
if err := os.WriteFile(configPath, configData, 0600); err != nil {
log.Printf("Warning: failed to write %s: %v", configPath, err)
continue
}
log.Printf("Registry auth configured at %s", configPath)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registry auth config may silently fail completely

Medium Severity

The setupRegistryAuth function returns nil unconditionally even if all config writes fail. The loop logs warnings and continues on each failure, but doesn't track whether at least one write succeeded. If both /home/builder/.docker and /root/.docker writes fail, the function reports success despite no authentication being configured. The build then fails later with a cryptic 401 error instead of a clear "failed to write docker config" message.

Fix in Cursor Fix in Web

}

log.Printf("Registry auth configured for %s", registryURL)
return nil
}

Expand Down
71 changes: 55 additions & 16 deletions lib/middleware/oapi_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,28 @@ const userIDKey contextKey = "user_id"
// It properly parses repository names (which can contain slashes) from /v2/ paths.
var registryRouter = v2.Router()

// RepoPermission defines access permissions for a specific repository.
// This mirrors the type in lib/builds/registry_token.go to avoid circular imports.
type RepoPermission struct {
Repo string `json:"repo"`
Scope string `json:"scope"`
}

// RegistryTokenClaims contains the claims for a scoped registry access token.
// This mirrors the type in lib/builds/registry_token.go to avoid circular imports.
type RegistryTokenClaims struct {
jwt.RegisteredClaims
BuildID string `json:"build_id"`
Repositories []string `json:"repos"`
Scope string `json:"scope"`
BuildID string `json:"build_id"`

// RepoAccess defines per-repository access permissions (new two-tier format)
// If present, this takes precedence over the legacy Repositories/Scope fields
RepoAccess []RepoPermission `json:"repo_access,omitempty"`

// Repositories is the list of allowed repository paths (legacy format)
Repositories []string `json:"repos,omitempty"`

// Scope is the access scope (legacy format)
Scope string `json:"scope,omitempty"`
}

// OapiAuthenticationFunc creates an AuthenticationFunc compatible with nethttp-middleware
Expand Down Expand Up @@ -116,6 +131,13 @@ func OapiAuthenticationFunc(jwtSecret string) openapi3filter.AuthenticationFunc
// that returns consistent error responses.
func OapiErrorHandler(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")

// For 401 responses, include WWW-Authenticate header so Docker/BuildKit
// knows to send credentials from the Docker config
if statusCode == http.StatusUnauthorized {
w.Header().Set("WWW-Authenticate", `Basic realm="registry"`)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WWW-Authenticate header incorrectly added to all API 401s

Low Severity

The OapiErrorHandler function now adds WWW-Authenticate: Basic realm="registry" to all 401 responses, not just registry endpoints. This affects non-registry API endpoints like /instances/{id}/exec, /instances/{id}/cp, and all OpenAPI-validated endpoints. These endpoints expect Bearer authentication, so returning a Basic auth challenge with a "registry" realm is misleading and could confuse API clients about how to authenticate.

Fix in Cursor Fix in Web


w.WriteHeader(statusCode)

// Return a simple JSON error response matching our Error schema
Expand Down Expand Up @@ -197,8 +219,11 @@ func isInternalVMRequest(r *http.Request) bool {
ip = ip[:idx]
}

// Check if it's from the VM network (10.100.x.x or 10.102.x.x)
return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.")
// Check if it's from the VM network
// BuildKit with registry.insecure=true doesn't do WWW-Authenticate challenge-response,
// so we need IP fallback for all internal subnets until we find a way to make
// BuildKit send auth proactively
return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") || strings.HasPrefix(ip, "172.30.")
}

// extractRepoFromPath extracts the repository name from a registry path.
Expand Down Expand Up @@ -229,6 +254,7 @@ func isWriteOperation(method string) bool {

// validateRegistryToken validates a registry-scoped JWT token and checks repository access.
// Returns the claims if valid, nil otherwise.
// Supports both new RepoAccess format and legacy Repositories/Scope format.
func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (*RegistryTokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &RegistryTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
Expand All @@ -246,8 +272,8 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (
return nil, fmt.Errorf("invalid token")
}

// Check if this is a registry token (has repos claim)
if len(claims.Repositories) == 0 {
// Check if this is a registry token (has either RepoAccess or legacy repos claim)
if len(claims.RepoAccess) == 0 && len(claims.Repositories) == 0 {
return nil, fmt.Errorf("not a registry token")
}

Expand All @@ -261,21 +287,34 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (
return nil, fmt.Errorf("could not extract repository from path")
}

// Check if the repository is allowed by the token
allowed := false
for _, allowedRepo := range claims.Repositories {
if allowedRepo == repo {
allowed = true
break
// Check if the repository is allowed by the token and get its scope
var repoScope string

// Check new RepoAccess format first
if len(claims.RepoAccess) > 0 {
for _, perm := range claims.RepoAccess {
if perm.Repo == repo {
repoScope = perm.Scope
break
}
}
} else {
// Fall back to legacy format
for _, allowedRepo := range claims.Repositories {
if allowedRepo == repo {
repoScope = claims.Scope
break
}
}
}
if !allowed {

if repoScope == "" {
return nil, fmt.Errorf("repository %s not allowed by token", repo)
}

// Check scope for write operations
if isWriteOperation(method) && claims.Scope != "push" {
return nil, fmt.Errorf("token does not allow write operations")
if isWriteOperation(method) && repoScope != "push" {
return nil, fmt.Errorf("token does not allow write operations for %s", repo)
}

return claims, nil
Expand Down
Loading