Files
seaweedfs/weed/admin/handlers/file_browser_handlers_test.go
T
Chris LuandDevin 39bdbc18db admin: keep read-only sessions out of the filer /etc tree (#12057)
* 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>
2026-10-25 06:06:21 -07:00

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())
}
}
}