Dagu has an incomplete fix for CVE-2026-27598: path traversal via %2F-encoded slashes in locateDAG
Description
Dagu is a workflow engine with a built-in Web user interface. From version 2.0.0 to before version 2.3.1, the fix for CVE-2026-27598 added ValidateDAGName to CreateNewDAG and rewrote generateFilePath to use filepath.Base. This patched the CREATE path. The remaining API endpoints - GET, DELETE, RENAME, EXECUTE - all pass the {fileName} URL path parameter to locateDAG without calling ValidateDAGName. %2F-encoded forward slashes in the {fileName} segment traverse outside the DAGs directory. This issue has been patched in version 2.3.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Dagu workflow engine fails to validate percent-encoded path separators in DAG file operations, allowing directory traversal outside the DAGs directory.
Analysis
Vulnerability
Details
CVE-2026-33344 is an incomplete fix for CVE-2026-27598. In Dagu, a workflow engine with a built-in Web UI, the initial patch added ValidateDAGName to CreateNewDAG and rewrote generateFilePath to use filepath.Base, but only protected the CREATE path. The remaining API endpoints—GET, DELETE, RENAME, EXECUTE, and others—pass the {fileName} URL path parameter to locateDAG without calling ValidateDAGName. As a result, %2F-encoded forward slashes in the {fileName} segment are decoded and traverse outside the DAGs directory [2][4].
The vulnerable function is locateDAG in internal/persis/filedag/store.go, which checks for a path separator but does not validate the path against the base directory. Chi v5 router preserves the raw URL path, so ..%2F..%2Fetc%2Ftarget.yaml becomes a single path segment that, after URL decoding, yields ../../etc/target.yaml. Since dagu binds the chi mux directly without Go's default path cleaning, the traversal succeeds [4].
Attack
Surface and Exploitation
An attacker can exploit endpoints such as: - GET /dags/{fileName}/spec reads arbitrary .yaml/.yml files via os.ReadFile. - DELETE /dags/{fileName} deletes arbitrary .yaml/.yml files via os.Remove. - POST /dags/{fileName}/start loads and executes arbitrary YAML as a workflow. Other endpoints like POST /dag-runs, POST /rename, /start-sync, /enqueue, and webhook handlers are similarly affected [4]. No authentication or special privileges are mentioned; the vulnerability is accessible through the web UI's API.
Impact
Successful exploitation allows an attacker to read, delete, or execute arbitrary YAML workflow files outside the intended DAGs directory. This could lead to information disclosure, denial of service via file deletion, or arbitrary workflow execution (potentially including command injection if the YAML defines malicious steps) [4].
Mitigation
The issue is patched in Dagu version 2.3.1. The fix introduces a middleware that validates the DAG file name on all relevant endpoints using core.ValidateDAGName, rejecting traversal patterns. Users should upgrade immediately [3][4].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/dagu-org/daguGo | >= 1.30.4-0.20260221021317-e2ed589105d7, < 1.30.4-0.20260319093346-7d07fda8f9de | 1.30.4-0.20260319093346-7d07fda8f9de |
Affected products
2Patches
17d07fda8f9defix: reject DAG file traversal via encoded slashes (#1803)
4 files changed · +145 −2
internal/service/frontend/api/v1/api.go+45 −1 modified@@ -21,6 +21,7 @@ import ( "github.com/dagu-org/dagu/internal/cmn/eval" "github.com/dagu-org/dagu/internal/cmn/logger" "github.com/dagu-org/dagu/internal/cmn/logger/tag" + "github.com/dagu-org/dagu/internal/core" "github.com/dagu-org/dagu/internal/core/baseconfig" "github.com/dagu-org/dagu/internal/core/exec" "github.com/dagu-org/dagu/internal/license" @@ -303,16 +304,59 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string) r.Use(WithRemoteNode(a.remoteNodeResolver, a.apiBasePath)) r.Use(WebhookRawBodyMiddleware()) + middlewares := []api.StrictMiddlewareFunc{validateDAGFileNameMiddleware} options := api.StrictHTTPServerOptions{ ResponseErrorHandlerFunc: a.handleError, } - handler := api.NewStrictHandlerWithOptions(a, nil, options) + handler := api.NewStrictHandlerWithOptions(a, middlewares, options) r.Mount("/", api.Handler(handler)) }) return nil } +func validateDAGFileNameMiddleware( + next api.StrictHandlerFunc, + _ string, +) api.StrictHandlerFunc { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) { + if err := validateDAGFileNameFromRequest(request); err != nil { + return nil, err + } + return next(ctx, w, r, request) + } +} + +func validateDAGFileNameFromRequest(request any) error { + v := reflect.ValueOf(request) + if !v.IsValid() { + return nil + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + + fileName := v.FieldByName("FileName") + if !fileName.IsValid() || fileName.Kind() != reflect.String { + return nil + } + + if err := core.ValidateDAGName(fileName.String()); err != nil { + return &Error{ + HTTPStatus: http.StatusBadRequest, + Code: api.ErrorCodeBadRequest, + Message: err.Error(), + } + } + return nil +} + func (a *API) evaluateAndNormalizeURL(ctx context.Context, baseURL string) string { if evaluated, err := eval.String(ctx, baseURL, eval.WithOSExpansion()); err != nil { logger.Warn(ctx, "Failed to evaluate API base URL",
internal/service/frontend/api/v1/dags_test.go+74 −0 modified@@ -4,6 +4,7 @@ package api_test import ( + "bytes" "fmt" "net/http" "testing" @@ -29,6 +30,40 @@ func getJSONWhenAvailable(t *testing.T, server test.Server, url string, out any) return true } +func sendRawRequestStatus( + t *testing.T, + server test.Server, + method string, + requestPath string, + body []byte, +) int { + t.Helper() + + baseURL := fmt.Sprintf( + "http://%s:%d", + server.Config.Server.Host, + server.Config.Server.Port, + ) + var bodyReader *bytes.Reader + if body == nil { + bodyReader = bytes.NewReader(nil) + } else { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequest(method, baseURL+requestPath, bodyReader) + require.NoError(t, err) + req.URL.RawPath = requestPath + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp.StatusCode +} + func TestDAGWritesDisabledInReadOnlyMode(t *testing.T) { // Setup server with gitSync.enabled=true, pushEnabled=false (read-only mode) server := test.SetupServer(t, test.WithConfigMutator(func(cfg *config.Config) { @@ -136,6 +171,45 @@ func TestCreateNewDAGPathTraversal(t *testing.T) { }) } +func TestDAGFileNameRejectsEncodedTraversal(t *testing.T) { + server := test.SetupServer(t) + + tests := []struct { + name string + method string + path string + body []byte + wantStatus int + }{ + { + name: "get spec", + method: http.MethodGet, + path: "/api/v1/dags/..%2F..%2Ftmp%2Fsecret/spec", + wantStatus: http.StatusBadRequest, + }, + { + name: "delete dag", + method: http.MethodDelete, + path: "/api/v1/dags/..%2F..%2Ftmp%2Fsecret", + wantStatus: http.StatusBadRequest, + }, + { + name: "start dag", + method: http.MethodPost, + path: "/api/v1/dags/..%2F..%2Ftmp%2Fsecret/start", + body: []byte(`{}`), + wantStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + status := sendRawRequestStatus(t, server, tc.method, tc.path, tc.body) + require.Equal(t, tc.wantStatus, status) + }) + } +} + func TestDAG(t *testing.T) { server := test.SetupServer(t)
internal/service/frontend/sse/multiplex_test.go+15 −0 modified@@ -34,6 +34,21 @@ func TestParseTopicRejectsMalformedQuery(t *testing.T) { require.Error(t, err) } +func TestParseTopicRejectsDAGTraversalIdentifiers(t *testing.T) { + tests := []string{ + "dag:../../tmp/secret.yaml", + "daghistory:..%2F..%2Ftmp%2Fsecret.yaml", + "dag:foo/bar", + } + + for _, topic := range tests { + t.Run(topic, func(t *testing.T) { + _, err := ParseTopic(topic) + require.Error(t, err) + }) + } +} + func TestParseInitialTopics(t *testing.T) { query := map[string][]string{ "topic": {"dag:test.yaml", "queueitems:default"},
internal/service/frontend/sse/topic_parse.go+11 −1 modified@@ -7,6 +7,8 @@ import ( "fmt" "regexp" "strings" + + "github.com/dagu-org/dagu/internal/core" ) var topicTypePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) @@ -69,7 +71,15 @@ func canonicalizeTopicIdentifier(topicType TopicType, identifier string) (string return pathPart, nil } return pathPart + "?" + queryPart, nil - case TopicTypeDAGRun, TopicTypeDAG, TopicTypeDAGHistory, TopicTypeStepLog, TopicTypeQueueItems, TopicTypeDoc: + case TopicTypeDAG, TopicTypeDAGHistory: + if identifier == "" { + return "", fmt.Errorf("topic %q requires an identifier", topicType) + } + if err := core.ValidateDAGName(identifier); err != nil { + return "", fmt.Errorf("invalid DAG file name: %w", err) + } + return identifier, nil + case TopicTypeDAGRun, TopicTypeStepLog, TopicTypeQueueItems, TopicTypeDoc: if identifier == "" { return "", fmt.Errorf("topic %q requires an identifier", topicType) }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-ph8x-4jfv-v9v8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33344ghsaADVISORY
- github.com/dagu-org/dagu/commit/7d07fda8f9de3ae73dfb081ccd0639f8059c56bbghsax_refsource_MISCWEB
- github.com/dagu-org/dagu/security/advisories/GHSA-ph8x-4jfv-v9v8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.