This commit is contained in:
Alka Kumari 2026-04-21 10:42:10 +03:00 committed by GitHub
commit d07d153d67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 243 additions and 2 deletions

View file

@ -506,7 +506,7 @@ start-e2e: test-tools-image
# Starts e2e server locally (or within a container)
.PHONY: start-e2e-local
start-e2e-local: mod-vendor-local dep-ui-local cli-local
start-e2e-local: mod-vendor-local dep-ui-local build-ui-local cli-local
kubectl create ns argocd-e2e || true
kubectl create ns argocd-e2e-external || true
kubectl create ns argocd-e2e-external-2 || true
@ -666,6 +666,10 @@ dep-ui: test-tools-image
dep-ui-local:
cd ui && pnpm install --frozen-lockfile
.PHONY: build-ui-local
build-ui-local:
cd ui && pnpm build
.PHONY: run-pnpm
run-pnpm: test-tools-image
$(call run-in-test-client,make 'PNPM_COMMAND=$(PNPM_COMMAND)' run-pnpm-local)

View file

@ -1480,11 +1480,42 @@ func (server *ArgoCDServer) newStaticAssetsHandler() func(http.ResponseWriter, *
}
w.Header().Set("Cache-Control", cacheControl)
}
http.FileServer(server.staticAssets).ServeHTTP(w, r)
http.FileServer(exposedFileSystem{server.staticAssets}).ServeHTTP(w, r)
}
}
}
// exposedFileSystem wraps http.FileSystem to disable directory listing
type exposedFileSystem struct {
fs http.FileSystem
}
func (efs exposedFileSystem) Open(path string) (http.File, error) {
f, err := efs.fs.Open(path)
if err != nil {
return nil, err
}
s, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
if s.IsDir() {
// Check if index.html exists inside the directory
index := filepath.Join(path, "index.html")
indexFile, err := efs.fs.Open(index)
if err != nil {
// No index.html → close the dir and return 404
_ = f.Close()
return nil, os.ErrNotExist
}
_ = indexFile.Close()
}
return f, nil
}
var mainJsBundleRegex = regexp.MustCompile(`^main\.[0-9a-f]{20}\.js$`)
func isMainJsBundle(url *url.URL) bool {

View file

@ -1839,3 +1839,70 @@ func Test_StaticAssetsDir_no_symlink_traversal(t *testing.T) {
resp = w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode, "should have been able to access the normal file")
}
// TestExposedFileSystem_HTTPHandler verifies directory-listing is disabled at the HTTP layer.
func TestExposedFileSystem_HTTPHandler(t *testing.T) {
t.Run("GET directory without index.html returns 404", func(t *testing.T) {
argocd, closer := fakeServer(t)
defer closer()
subdir := filepath.Join(argocd.TmpAssetsDir, "subdir")
err := os.Mkdir(subdir, 0o755)
require.NoError(t, err)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/subdir/", http.NoBody)
w := httptest.NewRecorder()
argocd.newStaticAssetsHandler()(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, "directory listing should be disabled")
})
t.Run("GET directory with index.html serves the directory", func(t *testing.T) {
argocd, closer := fakeServer(t)
defer closer()
subdir := filepath.Join(argocd.TmpAssetsDir, "subdir")
err := os.Mkdir(subdir, 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(subdir, "index.html"), []byte("<html>subdir</html>"), 0o644)
require.NoError(t, err)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/subdir/", http.NoBody)
w := httptest.NewRecorder()
argocd.newStaticAssetsHandler()(w, req)
assert.Equal(t, http.StatusOK, w.Code, "directory with index.html should be accessible")
})
t.Run("GET regular file inside directory is served", func(t *testing.T) {
argocd, closer := fakeServer(t)
defer closer()
subdir := filepath.Join(argocd.TmpAssetsDir, "static")
err := os.Mkdir(subdir, 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(subdir, "style.css"), []byte("body{}"), 0o644)
require.NoError(t, err)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/static/style.css", http.NoBody)
w := httptest.NewRecorder()
argocd.newStaticAssetsHandler()(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("GET nested directory without index.html returns 404", func(t *testing.T) {
argocd, closer := fakeServer(t)
defer closer()
nested := filepath.Join(argocd.TmpAssetsDir, "a", "b", "c")
err := os.MkdirAll(nested, 0o755)
require.NoError(t, err)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/a/b/c/", http.NoBody)
w := httptest.NewRecorder()
argocd.newStaticAssetsHandler()(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, "nested directory listing should be disabled")
})
}

View file

@ -14,6 +14,15 @@ import (
// DoHttpRequest executes a http request against the Argo CD API server
func DoHttpRequest(method string, path string, host string, data ...byte) (*http.Response, error) { //nolint:revive //FIXME(var-naming)
return doHTTPRequest(method, path, host, nil, data...)
}
// DoHTTPRequestWithHeaders is like DoHttpRequest but merges extra headers (e.g. Accept) into the request.
func DoHTTPRequestWithHeaders(method string, path string, host string, headers http.Header, data ...byte) (*http.Response, error) {
return doHTTPRequest(method, path, host, headers, data...)
}
func doHTTPRequest(method string, path string, host string, headers http.Header, data ...byte) (*http.Response, error) {
reqURL, err := url.Parse(path)
if err != nil {
return nil, err
@ -34,6 +43,11 @@ func DoHttpRequest(method string, path string, host string, data ...byte) (*http
}
req.AddCookie(&http.Cookie{Name: common.AuthCookieName, Value: token})
req.Header.Set("Content-Type", "application/json")
for name, vals := range headers {
for _, v := range vals {
req.Header.Add(name, v)
}
}
httpClient := &http.Client{
Transport: &http.Transport{

View file

@ -0,0 +1,125 @@
package e2e
import (
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "github.com/argoproj/argo-cd/v3/test/e2e/fixture"
. "github.com/argoproj/argo-cd/v3/test/e2e/fixture/app"
)
// TestDirectoryListingDisabled verifies that the ArgoCD API server never returns
// an HTML directory listing for any directory path, and that the exposedFileSystem
// wrapper correctly blocks directory enumeration at the HTTP layer.
func TestDirectoryListingDisabled(t *testing.T) {
testCases := []struct {
name string
path string
acceptHeader string
expectedStatus int
// bodyMustNotContain is checked when the status is 200/404 to make sure
// the server never returns a raw directory listing.
bodyMustNotContain []string
}{
{
name: "root with Accept:text/html serves SPA index",
path: "/",
acceptHeader: "text/html",
expectedStatus: http.StatusOK,
},
{
name: "root without Accept header serves embedded index.html",
path: "/",
acceptHeader: "",
expectedStatus: http.StatusOK,
// Should never be a raw directory listing.
bodyMustNotContain: []string{"<pre>", "Index of"},
},
{
name: "assets directory without index.html returns 404",
path: "/assets/",
acceptHeader: "",
expectedStatus: http.StatusNotFound,
bodyMustNotContain: []string{
"<pre>", "Index of",
"<a href=\"favicon/\">favicon/</a>", "<a href=\"fonts/\">fonts/</a>", "<a href=\"images/\">images/</a>",
"favicon", "fonts.css",
},
},
{
name: "assets directory without trailing slash returns 404",
path: "/assets",
acceptHeader: "",
expectedStatus: http.StatusNotFound,
bodyMustNotContain: []string{"<pre>", "Index of"},
},
{
name: "nested directory without index.html returns 404",
path: "/assets/favicon/",
acceptHeader: "",
expectedStatus: http.StatusNotFound,
bodyMustNotContain: []string{"<pre>", "Index of", "favicon-16x16.png", "favicon-32x32.png"},
},
{
name: "static file inside directory is served normally",
path: "/assets/favicon/favicon.ico",
acceptHeader: "",
expectedStatus: http.StatusOK,
},
{
name: "non-existent path returns 404 not directory listing",
path: "/this-path-does-not-exist/",
acceptHeader: "",
expectedStatus: http.StatusNotFound,
bodyMustNotContain: []string{"<pre>", "Index of"},
},
{
name: "assets directory with Accept:text/html",
path: "/assets/",
acceptHeader: "text/html",
// when Accept:text/html, the server serves index.html for HTML clients
expectedStatus: http.StatusOK,
bodyMustNotContain: []string{"<pre>", "<a href=\"images/\">images/</a>", "<a href=\"favicon/\">favicon/</a>"},
},
}
Given(t).
When().
And(func() {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var (
resp *http.Response
err error
)
if tc.acceptHeader != "" {
h := make(http.Header)
h.Set("Accept", tc.acceptHeader)
resp, err = DoHTTPRequestWithHeaders(http.MethodGet, tc.path, "", h)
} else {
resp, err = DoHttpRequest(http.MethodGet, tc.path, "")
}
require.NoError(t, err)
defer func() {
require.NoError(t, resp.Body.Close())
}()
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
if len(tc.bodyMustNotContain) > 0 {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
for _, forbidden := range tc.bodyMustNotContain {
assert.NotContains(t, bodyStr, forbidden,
"response body must not contain %q (directory listing must be disabled)", forbidden)
}
}
})
}
})
}