VYPR
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.

PackageAffected versionsPatched versions
github.com/argoproj/argo-workflows/v3Go
< 3.6.173.6.17
github.com/argoproj/argo-workflows/v3Go
>= 3.7.0, < 3.7.83.7.8
github.com/argoproj/argo-workflowsGo
<= 2.5.3-rc4

Affected products

1

Patches

1
159a5c56285e

fix(security): stored XSS in artifact directory listings (#15255)

https://github.com/argoproj/argo-workflowsMason MaloneJan 20, 2026via ghsa
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\&#34;&gt;&lt;img src=x onerror=&#34;alert(document.domain)&#34;&gt;.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">&lt;Input value = &#34;XSS&#34; type = text&gt;</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\&#34;&gt;&lt;img src=x onerror=&#34;alert(document.domain)&#34;&gt;.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">&lt;Input value = &#34;XSS&#34; type = text&gt;</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

News mentions

0

No linked articles in our index yet.