mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 09:20:50 +01:00
* admin: keep read-only sessions out of the filer /etc tree The file browser read routes are open to any authenticated session, so a read-only login could download or view arbitrary filer paths. The filer stores the credential and cluster configuration under /etc (identity JSON with plaintext S3 secret keys, SSE-S3 keys, remote storage config), which let a view-only session recover live secrets that the user API redacts. Gate the read paths on the session role: read-only sessions get 403 for /api/files/download, /view, /properties, /metadata on /etc paths, the browser page redirects away, and recursive metadata exports skip the /etc subtree for read-only sessions. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * admin: only reject real .. path components in file browser paths path.Clean already resolves interior parent references, so any '..' surviving in the cleaned path can only be a leading escape from a relative input or part of a valid name. Matching on the substring rejected legitimate entries like /buckets/reports/report..csv. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * admin: cover the read-only /etc export exclusion with a test Pull the excluded-prefix selection into exportExcludedPrefixes so the role-dependent /etc skip is exercised directly — the cleanReadPath tests alone would still pass if the exclusion were dropped. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
146 lines
5.0 KiB
Go
146 lines
5.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
)
|
|
|
|
func TestValidateAndCleanFilePath_AllowsControlChars(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
|
|
// S3 object keys may legally contain any UTF-8 bytes, including control
|
|
// characters like \n, \r, and \x00. The admin UI must be able to browse
|
|
// and manage such entries rather than silently stripping or rejecting them.
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"/buckets/profilebuilder/3testGB.zip\n ", "/buckets/profilebuilder/3testGB.zip\n "},
|
|
{"/foo\rbar", "/foo\rbar"},
|
|
{"/foo\x00bar", "/foo\x00bar"},
|
|
{"/normal/path.txt", "/normal/path.txt"},
|
|
// Missing leading slash should be added back.
|
|
{"relative/path.txt", "/relative/path.txt"},
|
|
// Duplicate slashes should be collapsed by path.Clean.
|
|
{"/a//b", "/a/b"},
|
|
// ".." inside a file or directory name is not a parent component.
|
|
{"/buckets/reports/report..csv", "/buckets/reports/report..csv"},
|
|
{"/a../b", "/a../b"},
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := h.validateAndCleanFilePath(tc.in)
|
|
if err != nil {
|
|
t.Errorf("validateAndCleanFilePath(%q) unexpected error: %v", tc.in, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("validateAndCleanFilePath(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateAndCleanFilePath_RejectsEmpty(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
if _, err := h.validateAndCleanFilePath(""); err == nil {
|
|
t.Errorf("expected empty path rejection")
|
|
}
|
|
}
|
|
|
|
func TestValidateAndCleanFilePath_RejectsParentComponents(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
for _, p := range []string{"../etc", "../../etc/iam", "..", "a/../../b"} {
|
|
if _, err := h.validateAndCleanFilePath(p); err == nil {
|
|
t.Errorf("validateAndCleanFilePath(%q): expected traversal rejection", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCleanReadPath_DeniesEtcForReadOnly(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
|
|
denied := []string{"/etc", "/etc/iam", "/etc/iam/identities/root.json",
|
|
"/etc/s3/kek.json", "/etc/../etc/iam/identity.json", "/a/../../etc/x"}
|
|
for _, p := range denied {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/download?path="+p, nil)
|
|
req = req.WithContext(dash.WithAuthContext(req.Context(), "ro", dash.RoleReadOnly, "csrf"))
|
|
w := httptest.NewRecorder()
|
|
|
|
if _, ok := h.cleanReadPath(w, req); ok {
|
|
t.Errorf("path %q: expected denial for read-only session", p)
|
|
continue
|
|
}
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("path %q: expected 403, got %d", p, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCleanReadPath_AllowsEtcForAdminAndNorRole(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
|
|
for _, role := range []string{"admin", ""} {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/download?path=/etc/iam/identities/root.json", nil)
|
|
req = req.WithContext(dash.WithAuthContext(req.Context(), "u", role, "csrf"))
|
|
w := httptest.NewRecorder()
|
|
|
|
if _, ok := h.cleanReadPath(w, req); !ok {
|
|
t.Errorf("role %q: expected /etc path to be allowed, got %d", role, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCleanReadPath_AllowsReadOnlyOutsideEtc(t *testing.T) {
|
|
h := &FileBrowserHandlers{}
|
|
|
|
for _, p := range []string{"/buckets/foo/bar.txt", "/etcx/file", "/etcetera", "/"} {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/download?path="+p, nil)
|
|
req = req.WithContext(dash.WithAuthContext(req.Context(), "ro", dash.RoleReadOnly, "csrf"))
|
|
w := httptest.NewRecorder()
|
|
|
|
if _, ok := h.cleanReadPath(w, req); !ok {
|
|
t.Errorf("path %q: expected allowed for read-only session, got %d", p, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExportExcludedPrefixes_ReadOnlyDropsEtc(t *testing.T) {
|
|
etcPrefix := strings.TrimSuffix(filer.DirectoryEtcRoot, "/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/metadata/export?path=/", nil)
|
|
req = req.WithContext(dash.WithAuthContext(req.Context(), "ro", dash.RoleReadOnly, "csrf"))
|
|
if got := exportExcludedPrefixes(req); !slices.Contains(got, etcPrefix) {
|
|
t.Errorf("read-only export should exclude %q, got %v", etcPrefix, got)
|
|
}
|
|
|
|
for _, role := range []string{"admin", ""} {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/metadata/export?path=/", nil)
|
|
req = req.WithContext(dash.WithAuthContext(req.Context(), "u", role, "csrf"))
|
|
if got := exportExcludedPrefixes(req); slices.Contains(got, etcPrefix) {
|
|
t.Errorf("role %q: export should keep %q, got %v", role, etcPrefix, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListFolders_RejectsPathOutsideBuckets(t *testing.T) {
|
|
h := &FileBrowserHandlers{adminServer: &dash.AdminServer{}}
|
|
|
|
cases := []string{"/etc/seaweedfs", "/", "/notbuckets/foo", "/buckets-not-really/foo",
|
|
"/buckets/../etc", "/buckets/..", "/buckets/foo/../../etc", `/buckets\..\etc`}
|
|
for _, path := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/list-folders?path="+path, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ListFolders(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("path %q: expected 400, got %d (body: %s)", path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|