mirror of
https://github.com/argoproj/argo-cd
synced 2026-04-21 17:07:16 +00:00
Merge c8428a0c15 into a7853eb7b6
This commit is contained in:
commit
d07d153d67
5 changed files with 243 additions and 2 deletions
6
Makefile
6
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
125
test/e2e/server_directory_listing_test.go
Normal file
125
test/e2e/server_directory_listing_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue