High severityOSV Advisory· Published Jan 21, 2026· Updated Jan 22, 2026
Argo Workflows affected by stored XSS in the artifact directory listing
CVE-2026-23960
Description
Argo Workflows is an open source container-native workflow engine for orchestrating parallel jobs on Kubernetes. Prior to versions 3.6.17 and 3.7.8, stored XSS in the artifact directory listing allows any workflow author to execute arbitrary JavaScript in another user’s browser under the Argo Server origin, enabling API actions with the victim’s privileges. Versions 3.6.17 and 3.7.8 fix the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/argoproj/argo-workflows/v3Go | < 3.6.17 | 3.6.17 |
github.com/argoproj/argo-workflows/v3Go | >= 3.7.0, < 3.7.8 | 3.7.8 |
github.com/argoproj/argo-workflowsGo | <= 2.5.3-rc4 | — |
Affected products
1- Range: ui-v3-rc1, v2.0.0, v2.0.0-alpha1, …
Patches
1159a5c56285efix(security): stored XSS in artifact directory listings (#15255)
3 files changed · +159 −45
server/artifacts/artifact_server.go+53 −26 modified@@ -1,9 +1,11 @@ package artifacts import ( + "bytes" "context" "errors" "fmt" + "html/template" "io" "mime" "net/http" @@ -218,31 +220,14 @@ func (a *ArtifactServer) GetArtifactFile(w http.ResponseWriter, r *http.Request) return } } - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("<html><body><ul>\n")) - - dirs := map[string]bool{} // to de-dupe sub-dirs - - _, _ = fmt.Fprintf(w, "<li><a href=\"%s\">%s</a></li>\n", "..", "..") - - for _, object := range objects { - - // object is prefixed the key, we must trim it - dir, file := path.Split(strings.TrimPrefix(object, key+"/")) - - // if dir is empty string, we are in the root dir - if dir == "" { - _, _ = fmt.Fprintf(w, "<li><a href=\"%s\">%s</a></li>\n", file, file) - } else if dirs[dir] { - continue - } else { - _, _ = fmt.Fprintf(w, "<li><a href=\"%s\">%s</a></li>\n", dir, dir) - dirs[dir] = true - } + a.setSecurityHeaders(w) + output, err := a.renderDirectoryListing(objects, key) + if err != nil { + a.serverInternalError(ctx, err, w) + return } - _, _ = w.Write([]byte("</ul></body></html>")) - + w.WriteHeader(http.StatusOK) + _, _ = w.Write(output) } else { // stream the file itself a.logger.WithFields(logging.Fields{ "artifact": artifact, @@ -257,6 +242,42 @@ func (a *ArtifactServer) GetArtifactFile(w http.ResponseWriter, r *http.Request) } +func (a *ArtifactServer) renderDirectoryListing(objects []string, key string) ([]byte, error) { + output := bytes.NewBufferString("<html><body><ul>\n<li><a href=\"..\">..</a></li>\n") + + dirs := map[string]bool{} // to de-dupe sub-dirs + + // Use html/template to prevent XSS attacks. + // The "./" prefix is necessary so the template engine recognizes it as a relative URL. + // Without that, a file called "javascript:alert(1)" would be escaped to "#ZgotmplZ" by the urlFilter. + tmpl, err := template.New("list").Parse("<li><a href=\"./{{.}}\">{{.}}</a></li>\n") + if err != nil { + return nil, err + } + + for _, object := range objects { + + // object is prefixed the key, we must trim it + dir, file := path.Split(strings.TrimPrefix(object, key+"/")) + + // if dir is empty string, we are in the root dir + if dir == "" { + if err = tmpl.Execute(output, file); err != nil { + return nil, err + } + } else if dirs[dir] { + continue + } else { + if err = tmpl.Execute(output, dir); err != nil { + return nil, err + } + dirs[dir] = true + } + } + _, _ = output.WriteString("</ul></body></html>") + return output.Bytes(), nil +} + func (a *ArtifactServer) getArtifact(w http.ResponseWriter, r *http.Request, isInput bool) { requestPath := strings.SplitN(r.URL.Path, "/", 6) if len(requestPath) != 6 { @@ -502,6 +523,13 @@ func (a *ArtifactServer) getArtifactAndDriver(ctx context.Context, nodeID, artif return art, driver, nil } +func (a *ArtifactServer) setSecurityHeaders(w http.ResponseWriter) { + // Set strict CSP headers for defense-in-depth against XSS: https://web.dev/articles/strict-csp + w.Header().Add("Content-Security-Policy", env.GetString("ARGO_ARTIFACT_CONTENT_SECURITY_POLICY", "sandbox; base-uri 'none'; default-src 'none'; img-src 'self'; style-src 'self' 'unsafe-inline'")) + // Mitigate clickjacking attacks + w.Header().Add("X-Frame-Options", env.GetString("ARGO_ARTIFACT_X_FRAME_OPTIONS", "SAMEORIGIN")) +} + func (a *ArtifactServer) returnArtifact(ctx context.Context, w http.ResponseWriter, art *wfv1.Artifact, driver common.ArtifactDriver) error { logger := logging.RequireLoggerFromContext(ctx) stream, err := driver.OpenStream(ctx, art) @@ -518,8 +546,7 @@ func (a *ArtifactServer) returnArtifact(ctx context.Context, w http.ResponseWrit key, _ := art.GetKey() w.Header().Add("Content-Disposition", fmt.Sprintf(`filename="%s"`, path.Base(key))) w.Header().Add("Content-Type", mime.TypeByExtension(path.Ext(key))) - w.Header().Add("Content-Security-Policy", env.GetString("ARGO_ARTIFACT_CONTENT_SECURITY_POLICY", "sandbox; base-uri 'none'; default-src 'none'; img-src 'self'; style-src 'self' 'unsafe-inline'")) - w.Header().Add("X-Frame-Options", env.GetString("ARGO_ARTIFACT_X_FRAME_OPTIONS", "SAMEORIGIN")) + a.setSecurityHeaders(w) _, err = io.Copy(w, stream) if err != nil {
server/artifacts/artifact_server_test.go+97 −16 modified@@ -134,18 +134,27 @@ func (a *fakeArtifactDriver) ListObjects(_ context.Context, artifact *wfv1.Artif return nil, err } if artifact.Name == "my-s3-artifact-directory" { + prefix := "my-wf/my-node-1/my-s3-artifact-directory" + subdir := []string{ + prefix + "/subdirectory/b.txt", + prefix + "/subdirectory/c.txt", + } + // XSS test strings. Loosely adapted from https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html#waf-bypass-strings-for-xss + xss := []string{ + prefix + `/xss/xss\"><img src=x onerror="alert(document.domain)">.html`, + prefix + `/xss/javascript:alert(document.domain)`, + prefix + `/xss/javascript:\u0061lert(1)`, + prefix + `/xss/<Input value = "XSS" type = text>`, + } if strings.HasSuffix(key, "subdirectory") { - return []string{ - "my-wf/my-node-1/my-s3-artifact-directory/subdirectory/b.txt", - "my-wf/my-node-1/my-s3-artifact-directory/subdirectory/c.txt", - }, nil + return subdir, nil + } else if strings.HasSuffix(key, "xss") { + return xss, nil } else { - return []string{ - "my-wf/my-node-1/my-s3-artifact-directory/a.txt", - "my-wf/my-node-1/my-s3-artifact-directory/index.html", - "my-wf/my-node-1/my-s3-artifact-directory/subdirectory/b.txt", - "my-wf/my-node-1/my-s3-artifact-directory/subdirectory/c.txt", - }, nil + return append(append([]string{ + prefix + "/a.txt", + prefix + "/index.html", + }, subdir...), xss...), nil } } return []string{}, nil @@ -402,9 +411,21 @@ func TestArtifactServer_GetArtifactFile(t *testing.T) { statusCode: 200, isDirectory: true, directoryFiles: []string{ - "..", - "b.txt", - "c.txt", + `<a href="..">..</a>`, + `<a href="./b.txt">b.txt</a>`, + `<a href="./c.txt">c.txt</a>`, + }, + }, + { + path: "/artifact-files/my-ns/workflows/my-wf/my-node-1/outputs/my-s3-artifact-directory/xss/", + statusCode: 200, + isDirectory: true, + directoryFiles: []string{ + `<a href="..">..</a>`, + `<a href="./xss%5c%22%3e%3cimg%20src=x%20onerror=%22alert%28document.domain%29%22%3e.html">xss\"><img src=x onerror="alert(document.domain)">.html</a>`, + `<a href="./javascript:alert%28document.domain%29">javascript:alert(document.domain)</a></li>`, + `<a href="./javascript:%5cu0061lert%281%29">javascript:\u0061lert(1)</a>`, + `<a href="./%3cInput%20value%20=%20%22XSS%22%20type%20=%20text%3e"><Input value = "XSS" type = text></a>`, }, }, { @@ -417,9 +438,9 @@ func TestArtifactServer_GetArtifactFile(t *testing.T) { statusCode: 200, isDirectory: true, directoryFiles: []string{ - "..", - "b.txt", - "c.txt", + `<a href="..">..</a>`, + `<a href="./b.txt">b.txt</a>`, + `<a href="./c.txt">c.txt</a>`, }, }, { @@ -476,6 +497,8 @@ func TestArtifactServer_GetArtifactFile(t *testing.T) { } if tt.isDirectory { fmt.Printf("got directory listing:\n%s\n", all) + assert.Contains(t, recorder.Header().Get("Content-Security-Policy"), "sandbox") + assert.Equal(t, "SAMEORIGIN", recorder.Header().Get("X-Frame-Options")) // verify that the files are contained in the listing we got back assert.Len(t, tt.directoryFiles, strings.Count(string(all), "<li>")) for _, file := range tt.directoryFiles { @@ -490,6 +513,64 @@ func TestArtifactServer_GetArtifactFile(t *testing.T) { } } +func TestArtifactServer_RenderDirectoryListings(t *testing.T) { + s := newServer(t) + + t.Run("Empty Directory", func(t *testing.T) { + expected := `<html><body><ul> +<li><a href="..">..</a></li> +</ul></body></html>` + actual, err := s.renderDirectoryListing([]string{}, "") + require.NoError(t, err) + assert.Equal(t, expected, string(actual)) + }) + + t.Run("Single File", func(t *testing.T) { + expected := `<html><body><ul> +<li><a href="..">..</a></li> +<li><a href="./foo.html">foo.html</a></li> +</ul></body></html>` + actual, err := s.renderDirectoryListing([]string{"foo.html"}, "") + require.NoError(t, err) + assert.Equal(t, expected, string(actual)) + }) + + t.Run("Nested Files", func(t *testing.T) { + expected := `<html><body><ul> +<li><a href="..">..</a></li> +<li><a href="./foo.html">foo.html</a></li> +<li><a href="./dir/">dir/</a></li> +<li><a href="./dir2/">dir2/</a></li> +</ul></body></html>` + actual, err := s.renderDirectoryListing([]string{ + "dir/foo.html", + "dir/dir/bar.html", + "dir/dir2/baz.html", + "dir/dir/bar2.html", + }, "dir") + require.NoError(t, err) + assert.Equal(t, expected, string(actual)) + }) + + t.Run("XSS Filtering", func(t *testing.T) { + expected := `<html><body><ul> +<li><a href="..">..</a></li> +<li><a href="./xss%5c%22%3e%3cimg%20src=x%20onerror=%22alert%28document.domain%29%22%3e.html">xss\"><img src=x onerror="alert(document.domain)">.html</a></li> +<li><a href="./javascript:alert%28document.domain%29">javascript:alert(document.domain)</a></li> +<li><a href="./javascript:%5cu0061lert%281%29">javascript:\u0061lert(1)</a></li> +<li><a href="./%3cInput%20value%20=%20%22XSS%22%20type%20=%20text%3e"><Input value = "XSS" type = text></a></li> +</ul></body></html>` + actual, err := s.renderDirectoryListing([]string{ + `xss\"><img src=x onerror="alert(document.domain)">.html`, + `javascript:alert(document.domain)`, + `javascript:\u0061lert(1)`, + `<Input value = "XSS" type = text>`, + }, "") + require.NoError(t, err) + assert.Equal(t, expected, string(actual)) + }) +} + func TestArtifactServer_GetOutputArtifact(t *testing.T) { s := newServer(t)
test/e2e/argo_server_test.go+9 −3 modified@@ -1728,7 +1728,13 @@ func (s *ArgoServerSuite) artifactServerRetrievalTests(name string, uid types.UI Status(200) resp.Body(). - Contains("<a href=\"subdirectory/\">subdirectory/</a>") + Contains("<a href=\"./subdirectory/\">subdirectory/</a>") + + resp.Header("Content-Security-Policy"). + IsEqual("sandbox; base-uri 'none'; default-src 'none'; img-src 'self'; style-src 'self' 'unsafe-inline'") + + resp.Header("X-Frame-Options"). + IsEqual("SAMEORIGIN") }) @@ -1739,8 +1745,8 @@ func (s *ArgoServerSuite) artifactServerRetrievalTests(name string, uid types.UI Status(200) resp.Body(). - Contains("<a href=\"sub-file-1\">sub-file-1</a>"). - Contains("<a href=\"sub-file-2\">sub-file-2</a>") + Contains("<a href=\"./sub-file-1\">sub-file-1</a>"). + Contains("<a href=\"./sub-file-2\">sub-file-2</a>") })
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-cv78-6m8q-ph82ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23960ghsaADVISORY
- github.com/argoproj/argo-workflows/blob/9872c296d29dcc5e9c78493054961ede9fc30797/server/artifacts/artifact_server.goghsax_refsource_MISCWEB
- github.com/argoproj/argo-workflows/commit/159a5c56285ecd4d3bb0a67aeef4507779a44e17ghsax_refsource_MISCWEB
- github.com/argoproj/argo-workflows/releases/tag/v3.6.17ghsax_refsource_MISCWEB
- github.com/argoproj/argo-workflows/releases/tag/v3.7.8ghsax_refsource_MISCWEB
- github.com/argoproj/argo-workflows/security/advisories/GHSA-cv78-6m8q-ph82ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.