mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-22 06:22:38 +00:00
s3: keep '\' a literal object-key character when AllowBackslashInKey is set
NormalizeObjectKey rewrites every S3 object key: '\' becomes '/' and duplicate slashes collapse. On AWS S3 both are ordinary key characters, so keys that are distinct there alias one object here — writing probe/back/slash.txt silently overwrites probe/back\slash.txt. The filer only treats '/' as a path separator, so a verbatim '\' stays inside one entry name. Gate the backslash rewrite and the matching '\' handling in IsValidObjectKey behind a process-wide mode the S3 server sets from the new S3ApiServerOption. Duplicate-slash collapsing stays: an entry name cannot contain an empty segment. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
a882565cd7
commit
a29795f88e
@@ -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, "/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user