Files
seaweedfs/weed/s3api/s3_constants/header_test.go
T
Chris LuandGitHub dd1b428789 s3,iceberg: reject .. in URL path vars (#9687)
* s3,iceberg: reject `..`/NUL in URL path vars

Both gateway routers use mux.NewRouter().SkipClean(true), so a request like
`GET /bucket-A/../evil-bucket/key` survives routing as bucket=bucket-A,
object=../evil-bucket/key. The captured key is then joined into a filer path;
util.JoinPath / path.Join collapse the `..` server-side and the read lands in
evil-bucket. With auth on, IAM still authorizes against bucket-A (the mux var),
so policy is evaluated against the wrong target.

Add a middleware on the S3 bucket subrouter and the Iceberg REST router that
rejects any `.`, `..`, NUL, or — for single-segment slots — embedded slash in
the captured path vars before any handler runs. NormalizeObjectKey already
folds `\` to `/` and decoding happens in mux, so `%2e%2e` and `..\` are caught.

* s3,iceberg: reject empty captured vars and empty namespace parts

Comma-ok the var lookup so we only check captured slots, then treat an empty
captured value as a rejection on its own — downstream path.Join would
otherwise collapse it and let the next segment pick the bucket.

For iceberg, also reject empty parts after splitting the namespace on \x1F so
leading/trailing/consecutive unit separators (which parseNamespace silently
folds out) don't let distinct route values collapse to the same parsed
namespace.

Register loggingMiddleware before validateRequestPath on the iceberg router
so rejected requests still produce an audit-log line.
2026-05-26 01:04:59 -07:00

245 lines
6.5 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 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)
}
}