Dagu: Path traversal in DAG creation allows arbitrary YAML file write outside DAGs directory
Description
Dagu is a workflow engine with a built-in Web user interface. In versions up to and including 1.16.7, the CreateNewDAG API endpoint (POST /api/v1/dags) does not validate the DAG name before passing it to the file store. An authenticated user with DAG write permissions can write arbitrary YAML files anywhere on the filesystem (limited by the process permissions). Since dagu executes DAG files as shell commands, writing a malicious DAG to the DAGs directory of another instance or overwriting config files can lead to remote code execution. Commit e2ed589105d79273e4e6ac8eb31525f765bb3ce4 fixes the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Dagu workflow engine <=1.16.7 has a path traversal in the CreateNewDAG API allowing authenticated users to write arbitrary YAML files, leading to remote code execution.
Vulnerability
Details
The CreateNewDAG API endpoint (POST /api/v1/dags) in Dagu versions up to and including 1.16.7 fails to validate the DAG name before passing it to the file store. Unlike the RenameDAG endpoint, which calls core.ValidateDAGName() to reject names containing path separators, CreateNewDAG skips this validation entirely [3]. The underlying generateFilePath function in internal/persis/filedag/store.go checks if the name contains a path separator and, if so, resolves it via filepath.Abs(name), completely ignoring the configured base directory [3]. This allows an attacker to supply a name like ../../tmp/pwned to write a file to an arbitrary location on the filesystem.
Exploitation
An authenticated user with DAG write permissions can exploit this by sending a crafted POST request to the vulnerable endpoint. The attacker controls both the file name (via path traversal) and the YAML content (the DAG specification) [2][3]. The file is written with the permissions of the dagu process. Since Dagu executes DAG files as shell commands, writing a malicious DAG to the DAGs directory of another instance or overwriting configuration files can lead to remote code execution [2].
Impact
Successful exploitation allows an attacker to achieve remote code execution as the dagu process user. By creating a DAG that contains arbitrary shell commands, the attacker can execute those commands when the DAG is run. Additionally, overwriting configuration files could alter the behavior of the workflow engine or other components [2][3].
Mitigation
The vulnerability is fixed in commit e2ed589105d79273e4e6ac8eb31525f765bb3ce4 [2]. Users should update to a version containing this fix. No workaround is documented. The issue is tracked as GHSA-6v48-fcq6-ff23 [3].
AI Insight generated on May 19, 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.16.7 | — |
Affected products
2Patches
1e2ed589105d7fix: create API: path traversal bug (#1691)
5 files changed · +95 −8
internal/core/validator.go+3 −0 modified@@ -36,6 +36,9 @@ func ValidateDAGName(name string) error { if name == "" { return nil } + if name == "." || name == ".." { + return ErrNameInvalidChars + } if len(name) > DAGNameMaxLen { return ErrNameTooLong }
internal/persis/filedag/store.go+11 −8 modified@@ -490,15 +490,18 @@ func (store *Storage) Rename(_ context.Context, oldID, newID string) error { } // generateFilePath generates the file path for a DAG by its name. +// It uses filepath.Base to strip directory components and verifies +// the result stays inside baseDir to prevent path traversal. func (store *Storage) generateFilePath(name string) string { - if strings.Contains(name, string(filepath.Separator)) { - filePath, err := filepath.Abs(name) - if err == nil { - return filePath - } - } - filePath := fileutil.EnsureYAMLExtension(path.Join(store.baseDir, name)) - return filepath.Clean(filePath) + safeName := filepath.Base(name) + filePath := fileutil.EnsureYAMLExtension(path.Join(store.baseDir, safeName)) + filePath = filepath.Clean(filePath) + // Verify the resolved path is inside baseDir. + basePrefix := filepath.Clean(store.baseDir) + string(filepath.Separator) + if !strings.HasPrefix(filePath, basePrefix) { + return filepath.Join(store.baseDir, "_invalid.yaml") + } + return filePath } // locateDAG locates the DAG file by its name or path.
internal/persis/filedag/store_test.go+26 −0 modified@@ -219,6 +219,32 @@ steps: assert.Equal(t, exec.ErrDAGAlreadyExists, err) } +func TestGenerateFilePathPreventsTraversal(t *testing.T) { + baseDir := "/base/dir" + store := New(baseDir, WithSkipExamples(true)).(*Storage) + + tests := []struct { + name string + input string + }{ + {"parent traversal", "../../tmp/pwned"}, + {"single parent", "../escape"}, + {"subdirectory", "foo/bar"}, + {"deep traversal", "../../../etc/malicious"}, + {"empty string", ""}, + {"dot dot", ".."}, + {"normal name", "my-dag"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := store.generateFilePath(tc.input) + assert.Equal(t, baseDir, filepath.Dir(result), + "generated path must be directly inside baseDir") + }) + } +} + func TestUpdateSpec(t *testing.T) { tmpDir := fileutil.MustTempDir("test-update-spec") defer func() {
internal/service/frontend/api/v1/dags.go+16 −0 modified@@ -122,6 +122,22 @@ func (a *API) CreateNewDAG(ctx context.Context, request api.CreateNewDAGRequestO return nil, err } + if request.Body.Name == "" { + return nil, &Error{ + HTTPStatus: http.StatusBadRequest, + Code: api.ErrorCodeBadRequest, + Message: "DAG name must not be empty", + } + } + + if err := core.ValidateDAGName(request.Body.Name); err != nil { + return nil, &Error{ + HTTPStatus: http.StatusBadRequest, + Code: api.ErrorCodeBadRequest, + Message: err.Error(), + } + } + var yamlSpec []byte if request.Body.Spec != nil && strings.TrimSpace(*request.Body.Spec) != "" { _, err := spec.LoadYAML(ctx,
internal/service/frontend/api/v1/dags_test.go+39 −0 modified@@ -81,6 +81,45 @@ func TestDAGWritesAllowedWhenGitSyncDisabled(t *testing.T) { server.Client().Delete("/api/v1/dags/test_dag_gitsync_disabled").ExpectStatus(http.StatusNoContent).Send(t) } +func TestCreateNewDAGPathTraversal(t *testing.T) { + server := test.SetupServer(t) + + traversalNames := []string{ + "../../tmp/traversal", + "../escape", + "foo/bar", + "../../../etc/malicious", + } + + for _, name := range traversalNames { + t.Run("with_spec/"+name, func(t *testing.T) { + spec := "steps:\n - command: echo test" + server.Client().Post("/api/v1/dags", api.CreateNewDAGJSONRequestBody{ + Name: name, + Spec: &spec, + }).ExpectStatus(http.StatusBadRequest).Send(t) + }) + + t.Run("without_spec/"+name, func(t *testing.T) { + server.Client().Post("/api/v1/dags", api.CreateNewDAGJSONRequestBody{ + Name: name, + }).ExpectStatus(http.StatusBadRequest).Send(t) + }) + } + + t.Run("empty_name", func(t *testing.T) { + server.Client().Post("/api/v1/dags", api.CreateNewDAGJSONRequestBody{ + Name: "", + }).ExpectStatus(http.StatusBadRequest).Send(t) + }) + + t.Run("dot_dot_name", func(t *testing.T) { + server.Client().Post("/api/v1/dags", api.CreateNewDAGJSONRequestBody{ + Name: "..", + }).ExpectStatus(http.StatusBadRequest).Send(t) + }) +} + func TestDAG(t *testing.T) { server := test.SetupServer(t)
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-6v48-fcq6-ff23ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27598ghsaADVISORY
- github.com/dagu-org/dagu/commit/e2ed589105d79273e4e6ac8eb31525f765bb3ce4ghsax_refsource_MISCWEB
- github.com/dagu-org/dagu/security/advisories/GHSA-6v48-fcq6-ff23ghsax_refsource_CONFIRMWEB
- pkg.go.dev/vuln/GO-2026-4542ghsaWEB
News mentions
0No linked articles in our index yet.