mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-17 20:57:27 +00:00
* s3: validate indirect filer path inputs * s3: avoid query parsing on common request path * filer: scope copy/move source against JWT AllowedPrefixes maybeCheckJwtAuthorization only checked r.URL.Path, but copy and move read their source from the cp.from / mv.from query params. A prefix-restricted token could copy or move data out of a subtree it cannot otherwise reach. Check every path the request touches, reusing pathHasComponentPrefix so `..` in the source is collapsed before the prefix match. * s3: confine iceberg CreateTable location to the catalog bucket CreateTable derived the metadata bucket and path from the client-supplied req.Location / req.Name and wrote there directly, so a caller scoped to one table bucket could place metadata in another bucket (and path.Join collapsed any `..`). Require the parsed bucket to equal the request's catalog bucket and reject traversal segments in the table path. * webdav: clean client path before subFolder confinement wrappedFs concatenated subFolder + name before the underlying FileSystem ran path.Clean, so `..` in the request path or COPY/MOVE Destination resolved across the FilerRootPath confinement boundary. Clean the name as a rooted path first so traversal segments collapse below subFolder. Only the non-default -filer.path (non-empty subFolder) setup was affected. * filer: enforce read-only rule on real write path with destination header The x-seaweedfs-destination header overrides the path used for storage-rule matching while the entry is written at r.URL.Path, letting a caller select a writable rule for a read-only target. When the header is present, also check the read-only/quota rule against the actual write path.
268 lines
7.0 KiB
Go
268 lines
7.0 KiB
Go
package s3_constants
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeObjectKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple key",
|
|
input: "file.txt",
|
|
expected: "file.txt",
|
|
},
|
|
{
|
|
name: "key with leading slash",
|
|
input: "/file.txt",
|
|
expected: "file.txt",
|
|
},
|
|
{
|
|
name: "key with directory",
|
|
input: "folder/file.txt",
|
|
expected: "folder/file.txt",
|
|
},
|
|
{
|
|
name: "key with leading slash and directory",
|
|
input: "/folder/file.txt",
|
|
expected: "folder/file.txt",
|
|
},
|
|
{
|
|
name: "key with duplicate slashes",
|
|
input: "folder//subfolder///file.txt",
|
|
expected: "folder/subfolder/file.txt",
|
|
},
|
|
{
|
|
name: "Windows backslash - simple",
|
|
input: "folder\\file.txt",
|
|
expected: "folder/file.txt",
|
|
},
|
|
{
|
|
name: "Windows backslash - nested",
|
|
input: "folder\\subfolder\\file.txt",
|
|
expected: "folder/subfolder/file.txt",
|
|
},
|
|
{
|
|
name: "Windows backslash - with leading slash",
|
|
input: "/folder\\subfolder\\file.txt",
|
|
expected: "folder/subfolder/file.txt",
|
|
},
|
|
{
|
|
name: "mixed slashes",
|
|
input: "folder\\subfolder/another\\file.txt",
|
|
expected: "folder/subfolder/another/file.txt",
|
|
},
|
|
{
|
|
name: "Windows full path style (edge case)",
|
|
input: "C:\\Users\\test\\file.txt",
|
|
expected: "C:/Users/test/file.txt",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "just a slash",
|
|
input: "/",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "just a backslash",
|
|
input: "\\",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := NormalizeObjectKey(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("NormalizeObjectKey(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidObjectKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want bool
|
|
}{
|
|
{"empty", "", true},
|
|
{"plain", "folder/file.txt", true},
|
|
{"leading slash", "/folder/file.txt", true},
|
|
{"trailing slash", "folder/", true},
|
|
{"hidden file ok", ".hidden", true},
|
|
{"dotdot in name ok", "..hidden", true},
|
|
{"double dots inside name", "foo..bar/baz", true},
|
|
|
|
{"bare dotdot", "..", false},
|
|
{"bare dot", ".", false},
|
|
{"leading dotdot segment", "../evil-bucket/test.txt", false},
|
|
{"leading dot-slash", "./evil/test.txt", false},
|
|
{"nested dotdot segment", "good/../evil/test.txt", false},
|
|
{"trailing dotdot segment", "good/..", false},
|
|
{"backslash dotdot", "..\\evil\\test.txt", false},
|
|
{"mixed-slash dotdot", "good\\..\\evil/test.txt", false},
|
|
{"dotdot after duplicate slash", "good//../evil", false},
|
|
{"nul byte", "foo\x00bar", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsValidObjectKey(tt.input); got != tt.want {
|
|
t.Errorf("IsValidObjectKey(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidBucketName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want bool
|
|
}{
|
|
{"empty ok", "", true},
|
|
{"plain", "my-bucket", true},
|
|
{"name containing dots", "my.bucket.name", true},
|
|
|
|
{"bare dot", ".", false},
|
|
{"bare dotdot", "..", false},
|
|
{"with slash", "evil/bucket", false},
|
|
{"with backslash", "evil\\bucket", false},
|
|
{"with nul", "evil\x00bucket", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsValidBucketName(tt.input); got != tt.want {
|
|
t.Errorf("IsValidBucketName(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidPathSegment(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"opaque-id_123", true},
|
|
{"..hidden", true},
|
|
{"", false},
|
|
{".", false},
|
|
{"..", false},
|
|
{"dir/value", false},
|
|
{"dir\\value", false},
|
|
{"nul\x00value", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
if got := IsValidPathSegment(tt.input); got != tt.want {
|
|
t.Errorf("IsValidPathSegment(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoveDuplicateSlashes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no duplicates",
|
|
input: "/folder/file.txt",
|
|
expected: "/folder/file.txt",
|
|
},
|
|
{
|
|
name: "double slash",
|
|
input: "/folder//file.txt",
|
|
expected: "/folder/file.txt",
|
|
},
|
|
{
|
|
name: "triple slash",
|
|
input: "/folder///file.txt",
|
|
expected: "/folder/file.txt",
|
|
},
|
|
{
|
|
name: "multiple duplicate locations",
|
|
input: "//folder//subfolder///file.txt",
|
|
expected: "/folder/subfolder/file.txt",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := removeDuplicateSlashes(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("removeDuplicateSlashes(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIdentityHolderPropagation reproduces the audit-log middleware chain: an
|
|
// outer handler installs the holder, an inner handler authenticates and records
|
|
// the identity on a request copy, and the outer handler must still recover the
|
|
// requester from its own (earlier) copy of the request.
|
|
func TestIdentityHolderPropagation(t *testing.T) {
|
|
outer := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
|
|
// Before the holder is installed there is no identity to recover.
|
|
if got := GetIdentityNameFromContext(outer); got != "" {
|
|
t.Fatalf("unauthenticated request reports requester %q, want empty", got)
|
|
}
|
|
|
|
outer = EnsureIdentityHolder(outer)
|
|
|
|
// The inner handler sets the identity on a copy, mirroring how auth wraps
|
|
// the request with r.WithContext before invoking the next handler.
|
|
innerCtx := SetIdentityNameInContext(outer.Context(), "admin")
|
|
inner := outer.WithContext(innerCtx)
|
|
|
|
if got := GetIdentityNameFromContext(inner); got != "admin" {
|
|
t.Errorf("inner request requester = %q, want admin", got)
|
|
}
|
|
if got := GetIdentityNameFromContext(outer); got != "admin" {
|
|
t.Errorf("outer request requester = %q, want admin (must propagate via holder)", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureIdentityHolderIdempotent(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
|
|
first := EnsureIdentityHolder(req)
|
|
if first == req {
|
|
t.Fatal("EnsureIdentityHolder should return a new request when no holder is present")
|
|
}
|
|
|
|
again := EnsureIdentityHolder(first)
|
|
if again != first {
|
|
t.Error("EnsureIdentityHolder should be idempotent when a holder is already present")
|
|
}
|
|
}
|
|
|
|
func TestGetIdentityNameFromContextWithoutHolder(t *testing.T) {
|
|
// Without a holder, the per-request context value is still honored so paths
|
|
// that set identity without installing a holder keep working.
|
|
req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
req = req.WithContext(SetIdentityNameInContext(req.Context(), "admin"))
|
|
|
|
if got := GetIdentityNameFromContext(req); got != "admin" {
|
|
t.Errorf("requester = %q, want admin", got)
|
|
}
|
|
}
|