diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index 28a2d1baf0..e415d65587 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -216,6 +216,20 @@ func GetBucketAndObject(r *http.Request) (bucket, object string) { return } +// allowBackslashInObjectKey keeps '\' a literal object-key character instead +// of normalizing it to '/'. It is a process-wide mode set once at S3 server +// startup; the filer only treats '/' as a path separator, so a verbatim '\' +// stays inside one filer entry name. +var allowBackslashInObjectKey atomic.Bool + +func SetAllowBackslashInObjectKey(allow bool) { + allowBackslashInObjectKey.Store(allow) +} + +func AllowBackslashInObjectKey() bool { + return allowBackslashInObjectKey.Load() +} + // IsValidObjectKey rejects S3 object keys that — after normalization // (backslash→slash, slash collapse) — contain a `.` or `..` path segment, or // embed a NUL byte. Such keys are collapsed by filepath.Join inside the filer @@ -229,7 +243,9 @@ func IsValidObjectKey(object string) bool { if strings.ContainsRune(object, '\x00') { return false } - object = strings.ReplaceAll(object, "\\", "/") + if !allowBackslashInObjectKey.Load() { + object = strings.ReplaceAll(object, "\\", "/") + } for _, seg := range strings.Split(object, "/") { if seg == "." || seg == ".." { return false @@ -271,7 +287,9 @@ func NormalizeObjectKey(object string) string { hasTrailingSlash := strings.HasSuffix(object, "/") // Convert Windows-style backslashes to forward slashes - object = strings.ReplaceAll(object, "\\", "/") + if !allowBackslashInObjectKey.Load() { + object = strings.ReplaceAll(object, "\\", "/") + } object = removeDuplicateSlashes(object) // Remove leading slash to match S3 API format object = strings.TrimPrefix(object, "/") diff --git a/weed/s3api/s3_constants/header_test.go b/weed/s3api/s3_constants/header_test.go index 7e45882710..de780d0d51 100644 --- a/weed/s3api/s3_constants/header_test.go +++ b/weed/s3api/s3_constants/header_test.go @@ -123,6 +123,54 @@ func TestIsValidObjectKey(t *testing.T) { } } +func TestNormalizeObjectKeyAllowBackslash(t *testing.T) { + SetAllowBackslashInObjectKey(true) + defer SetAllowBackslashInObjectKey(false) + + normalizeTests := []struct { + name string + input string + expected string + }{ + {"backslash kept verbatim", "folder\\file.txt", "folder\\file.txt"}, + {"nested backslash kept", "folder\\sub\\file.txt", "folder\\sub\\file.txt"}, + {"trailing backslash kept", "folder\\", "folder\\"}, + {"lone backslash kept", "\\", "\\"}, + {"double backslash kept", "a\\\\b", "a\\\\b"}, + {"mixed separators", "folder\\sub/another\\file.txt", "folder\\sub/another\\file.txt"}, + {"duplicate slashes still collapse", "folder//sub///file.txt", "folder/sub/file.txt"}, + {"leading slash still trimmed", "/folder\\file.txt", "folder\\file.txt"}, + {"trailing slash preserved", "folder/", "folder/"}, + {"windows path style", "C:\\Users\\test\\file.txt", "C:\\Users\\test\\file.txt"}, + } + for _, tt := range normalizeTests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeObjectKey(tt.input); got != tt.expected { + t.Errorf("NormalizeObjectKey(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } + + validTests := []struct { + name string + input string + want bool + }{ + {"backslash dotdot is one segment", "..\\evil\\test.txt", true}, + {"backslash dotdot inside", "good\\..\\evil/test.txt", true}, + {"nested dotdot still rejected", "good/../evil/test.txt", false}, + {"leading dotdot still rejected", "../evil/test.txt", false}, + {"plain backslash key", "folder\\file.txt", true}, + } + for _, tt := range validTests { + 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 diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index f7c0e477cf..24d69ee82c 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -78,6 +78,9 @@ type S3ApiServerOption struct { // AllowUntrustedRemoteEndpoints lets a read of a remote-only object dial a // mounted endpoint that resolves to a loopback / private / metadata host. AllowUntrustedRemoteEndpoints bool + // AllowBackslashInKey keeps '\' a literal object-key character instead of + // normalizing it to '/', matching AWS S3 where the two are distinct keys. + AllowBackslashInKey bool } // s3ChunkCacheChunkSizeMB is the assumed chunk size (in MiB) used to convert @@ -176,6 +179,8 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl return nil, fmt.Errorf("at least one filer address is required") } + SetAllowBackslashInObjectKey(option.AllowBackslashInKey) + startTsNs := time.Now().UnixNano() v := util.GetViper()