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:
Chris Lu
2026-10-21 14:56:51 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent a882565cd7
commit a29795f88e
3 changed files with 73 additions and 2 deletions
+20 -2
View File
@@ -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, "/")
+48
View File
@@ -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
+5
View File
@@ -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()