VYPR
High severityNVD Advisory· Published Mar 24, 2026· Updated Mar 24, 2026

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

CVE-2026-33344

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.

PackageAffected versionsPatched versions
github.com/dagu-org/daguGo
>= 1.30.4-0.20260221021317-e2ed589105d7, < 1.30.4-0.20260319093346-7d07fda8f9de1.30.4-0.20260319093346-7d07fda8f9de

Affected products

2
  • Dagu Org/Dagullm-fuzzy2 versions
    >=2.0.0, <2.3.1+ 1 more
    • (no CPE)range: >=2.0.0, <2.3.1
    • (no CPE)range: >= 2.0.0, < 2.3.1

Patches

1
7d07fda8f9de

fix: reject DAG file traversal via encoded slashes (#1803)

https://github.com/dagu-org/daguYota HamadaMar 19, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.