GitHub Advisoryadvisory · Go
GHSA-m4q3-457p-hh2x Dagu: Path Traversal via `dagRunId` in Inline DAG Execution
CRITICAL
Official page ↗
- Aliases
- CVE-2026-31886
- Ecosystem
- Go
- Packages
- github.com/dagu-org/dagu
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:H
- Weaknesses
- CWE-22
- Reviewed
- 2026-03-13
Affected packages
• Go github.com/dagu-org/dagu: <= 2.2.4 (no fix yet)
Description
1. Vulnerability Summary
The `dagRunId` request field accepted by the inline DAG execution endpoints is passed directly into `filepath.Join` to construct a temporary directory path without any format validation. Go's `filepath.Join` resolves `..` segments lexically, so a caller can supply a value such as `".."` to redirect the computed directory outside the intended `/tmp//` path. A deferred cleanup function that calls `os.RemoveAll` on that directory then runs unconditionally when the HTTP handler returns, deleting whatever directory the traversal resolved to.
With `dagRunId` set to `".."`, the resolved directory is the system temporary directory (`/tmp` on Linux). On non-root deployments, `os.RemoveAll("/tmp")` removes all files in `/tmp` owned by the dagu process user, disrupting every concurrent dagu run that has live temp files. On root or Docker deployments, the call removes the entire contents of `/tmp`, causing a system-wide denial of service.
2. This Is Not a Duplicate of Existing Advisories
Two security advisories are already published for dagu. This vulnerability is distinct from both.
GHSA-6qr9-g2xw-cw92 fixed the fact that the default authentication mode was `none`, allowing unauthenticated access to the inline execution endpoint. That advisory covers authentication bypass. The `dagRunId` path traversal described here is a separate input-validation flaw in `loadInlineDAG()` that exists regardless of whether authentication is required and was not addressed by that fix.
CVE-2026-27598 fixed a path traversal in the DAG creation endpoint (`POST /api/v1/dags`) via the `name` field. The fix added `filepath.Base()` and a base-directory prefix check inside `generateFilePath()`. That fix applies only to `generateFilePath()` in `dags.go`. The function `loadInlineDAG()` in `dagruns.go` has no equivalent guard on its `dagRunID` argument and was not part of that patch.
3. Vulnerable Code
File: `internal/service/frontend/api/v1/dagruns.go`
The `loadInlineDAG` function (lines 202-267) constructs the temp directory at line 234:
```go
tmpDir := filepath.Join(os.TempDir(), nameHint, dagRunID)
```
`dagRunID` is user-supplied. No validation of the value occurs before this line. The cleanup closure is then registered:
```go
cleanup := func() {
_ = os.RemoveAll(tmpDir)
}
```
In `ExecuteDAGRunFromSpec` (lines 52-119), the cleanup is deferred unconditionally:
```go
dag, cleanup, err := a.loadInlineDAG(ctx, request.Body.Spec, request.Body.Name, dagRunId)
if err != nil {
return nil, err
}
defer cleanup() // registered after loadInlineDAG succeeds; fires on all subsequent return paths
```
The same pattern appears in `EnqueueDAGRunFromSpec` (lines 122-200), line 160:
```go
defer cleanup()
```
Why the OpenAPI schema pattern does not prevent this:
The `DAGRunId` schema in `api/v1/api.yaml` (line 5738) declares:
```yaml
pattern: "^[a-zA-Z0-9_-]+$"
```
This pattern excludes `.` and `/`, which would block path traversal values. However, enforcement of that pattern depends on the OpenAPI validator middleware, which is only activated when `StrictValidation` is `true`. That setting is defined in `internal/cmn/config/config.go`:
```go
StrictValidation bool
```
It is not present in the `Definition` struct (`definition.go`) and carries no `mapstructure` tag, which means viper/mapstructure can never populate it from a YAML configuration file; it therefore cannot be set in the config loader (`loader.go`) and its value is always the Go zero value for `bool`, which is `false`. The loader test at line 165 of `loader_test.go` confirms that `StrictValidation` is `false` even after loading a comprehensive configuration file that exercises every configurable option — because there is no mechanism by which it could ever be `true`. The validator middleware is never registered for any standard dagu deployment.
The file `dagruns.go` defines a `sanitizeFilename` helper at line 36 that replaces characters outside `[a-zA-Z0-9._-]` with underscores. This function is called when constructing log filenames (lines 422, 566, 1127, and 1211) and is never applied to `dagRunID` before the `filepath.Join` call. No validation or sanitization of `dagRunID` for path separator characters exists anywhere in the request-to-`filepath.Join` pipeline.
4. Attack Conditions
• The attacker must be authenticated with a role of `operator`, `developer`, `manager`, or `admin` (any role for which `CanExecute()` returns true).
• The server permission `PermissionRunDAGs` must be enabled. This is the default (`true` as set in `loader.go` lines 353-356).
• On dagu versions 1.30.3 and earlier, where the default authentication mode was `none`, no authentication is required at all.
5. Attack Scenario
Step 1: Authenticate
```bash
TOKEN=$(curl -s -X POST http://TARGET:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"operator","password":""}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
```
For versions with `auth.mode: none`, authentication is not required and the `Authorization` header can be omitted.
Step 2: Send the malicious request
```bash
curl -s -X POST http://TARGET:8080/api/v1/dag-runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"spec": "steps:\n - name: s\n command: id\n",
"dagRunId": ".."
}'
```
Step 3: What happens inside the server
1. `request.Body.DagRunId` is `".."`. This value is copied to `dagRunId` at line 72 without modification.
2. `loadInlineDAG` is called with `dagRunID = ".."` and no `name` parameter, so `nameHint = "inline"`. Because `name` is nil, the `else` branch at lines 214-231 runs first: `spec.LoadYAML` parses the spec content and `dag.Validate()` checks its structure. This pre-validation operates entirely on the YAML content; it has no knowledge of `dagRunID`. The exploit spec (`steps:\n - name: s\n command: id\n`) passes this check. The `nameHint` variable is not updated by this parse — it stays `"inline"` regardless of any `name` field inside the spec YAML. Control reaches line 234 only after the spec is accepted.
3. Line 234 executes:
```
tmpDir = filepath.Join("/tmp", "inline", "..")
= filepath.Clean("/tmp/inline/..")
= "/tmp"
```
(`filepath.Join` calls `filepath.Clean` on the joined result, resolving `..` lexically.)
4. `os.MkdirAll("/tmp", 0o750)` succeeds because `/tmp` already exists.
5. The cleanup closure captures `tmpDir = "/tmp"`:
```go
cleanup = func() { os.RemoveAll("/tmp") }
```
6. The spec is written to `filepath.Join("/tmp", "inline.yaml")` = `/tmp/inline.yaml` and loaded via `spec.Load` (line 256). The load succeeds.
7. `loadInlineDAG` returns `dag, cleanup, nil`.
8. `defer cleanup()` is registered in `ExecuteDAGRunFromSpec`.
9. The handler builds the 200 response object. In Go, deferred functions execute during the function's return sequence — before control returns to chi's server wrapper. The deferred cleanup therefore fires first: `os.RemoveAll("/tmp")` runs and removes the target directory.
10. The handler returns the response object to chi. Chi serializes it and sends the HTTP 200 to the client. The 200 is delivered successfully because the response content was already constructed before the defer ran; the directory deletion does not affect the HTTP response.
Step 4: Result
On non-root deployments: all files in `/tmp` owned by the dagu process user are removed (Linux sticky bit prevents deletion of files owned by other users). Any concurrent dagu runs that have live temp files in `/tmp` lose those files and fail.
On root or Docker deployments (where dagu runs as root inside a container, which is a common production pattern): all contents of `/tmp` are removed, affecting every process on the system that uses `/tmp` for temporary storage.
The attack can be sent repeatedly without any cooldown, maintaining the d…
Related
Alias · CVE (1)
- CVE-2026-31886 Dagu is a workflow engine with a built-in Web user interface. vulnerability
Weakness · CWE (1)
- CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness