Overview
The Hatchet CVE-2026-42572 case is a textbook Broken Object Level Authorization (BOLA) bug in a multi-tenant system. Before version 0.83.39, the GET /api/v1/stable/dags/tasks endpoint lacked a proper per-object authorization check, allowing an authenticated user to query task metadata for a DAG belonging to another tenant by supplying that DAG's UUID, effectively bypassing tenant isolation. This enabled an attacker to harvest or view sensitive task metadata across tenants even when they were not the owner of the DAG. The vulnerability aligns with CWE-639 (Authorization Bailure) and CWE-863 (Incorrect Authorization). The patch in 0.83.39 fixes the root cause by enforcing a strict tenant-bound check on the requested DAG and its tasks. In Go with the Gin framework, this kind of flaw typically arises when an endpoint authenticates the user but neglects to validate that the object being accessed actually belongs to the authenticated user’s tenant. The remediation is to couple per-object authorization with a trustworthy tenant scoping mechanism, and to add explicit tests that simulate cross-tenant access attempts. The examples below illustrate a vulnerable pattern and a corrected pattern in Go (Gin).
Affected Versions
0.83.0 through 0.83.38 (prior to 0.83.39)
Code Fix Example
Go (Gin) API Security Remediation
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type User struct {
ID string
TenantID string
Admin bool
}
type DAG struct {
ID string
TenantID string
Tasks []string
}
var dags = map[string]DAG{
"dag1": {ID: "dag1", TenantID: "tenantA", Tasks: []string{"t1", "t2"}},
"dag2": {ID: "dag2", TenantID: "tenantB", Tasks: []string{"t3"}},
}
// pretendAuth is a simple auth middleware for demonstration. In production, replace with real JWT/token validation.
func pretendAuth() gin.HandlerFunc {
return func(c *gin.Context) {
tenant := c.GetHeader("X-Tenant")
if tenant == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
user := User{ID: "u1", TenantID: tenant, Admin: false}
c.Set("user", user)
c.Next()
}
}
// Vulnerable pattern: no per-object tenant check before returning tasks
func vulnerableGetDagsTasks(c *gin.Context) {
u := c.MustGet("user").(User)
dagID := c.Query("dag_id")
if dagID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dag_id required"})
return
}
dag, ok := dags[dagID]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "dag not found"})
return
}
// Missing: verify dag.TenantID == u.TenantID (or admin override)
c.JSON(http.StatusOK, gin.H{"requested_by": u.TenantID, "dag": dag.ID, "tasks": dag.Tasks})
}
// Fixed pattern: enforce object-level authorization by validating tenant ownership
func fixedGetDagsTasks(c *gin.Context) {
u := c.MustGet("user").(User)
dagID := c.Query("dag_id")
if dagID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dag_id required"})
return
}
dag, ok := dags[dagID]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "dag not found"})
return
}
// Enforce per-object authorization: only allow access if the DAG tenant matches the user tenant (or if user is admin)
if dag.TenantID != u.TenantID && !u.Admin {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.JSON(http.StatusOK, gin.H{"requested_by": u.TenantID, "dag": dag.ID, "tasks": dag.Tasks})
}
func main() {
r := gin.Default()
r.Use(pretendAuth())
// Vulnerable route (for illustration only; in production, remove or fix this)
r.GET("/vuln/api/v1/stable/dags/tasks", vulnerableGetDagsTasks)
// Fixed route with proper authorization checks
r.GET("/api/v1/stable/dags/tasks", fixedGetDagsTasks)
_ = r.Run(":8080")
}