fix: add object path validation util

This adds an object name validation util to check if the object
path would resolve to a path outside of the bucket directory.

S3 returns Bad Request for these type of paths:
 % aws s3api put-object --bucket mybucket --key test/../../hello
An error occurred (400) when calling the PutObject operation: Bad Request
This commit is contained in:
Ben McClelland
2025-06-30 16:57:03 -07:00
parent c4cfc8a08a
commit 7260854cd0
3 changed files with 259 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
// Copyright 2025 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
func IsObjectNameValid(name string) bool {
switch clean(name) {
case "", ".", "..", "/":
return false
}
return isObjectLocal(name)
}

171
s3api/utils/path.go Normal file
View File

@@ -0,0 +1,171 @@
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// code modified from golang std library src/internal/filepathlite/path.go
// to support path separator '/' for all platforms.
package utils
import (
"strings"
)
const separator = '/'
// isObjectLocal checks if the given path would result in an object
// that is local to the bucket.
func isObjectLocal(path string) bool {
if path == "" || path == "." {
return true
}
path = strings.Join([]string{".", path}, string(separator))
hasDots := false
for p := path; p != ""; {
var part string
part, p, _ = strings.Cut(p, "/")
if part == "." || part == ".." {
hasDots = true
break
}
}
if hasDots {
path = clean(path)
}
if path == ".." || strings.HasPrefix(path, "../") {
return false
}
return true
}
func clean(path string) string {
originalPath := path
if path == "" {
return originalPath + "."
}
rooted := isPathSeparator(path[0])
// Invariants:
// reading from path; r is index of next byte to process.
// writing to buf; w is index of next byte to write.
// dotdot is index in buf where .. must stop, either because
// it is the leading slash or it is a leading ../../.. prefix.
n := len(path)
out := lazybuf{path: path, volAndPath: originalPath, volLen: 0}
r, dotdot := 0, 0
if rooted {
out.append(separator)
r, dotdot = 1, 1
}
for r < n {
switch {
case isPathSeparator(path[r]):
// empty path element
r++
case path[r] == '.' && (r+1 == n || isPathSeparator(path[r+1])):
// . element
r++
case path[r] == '.' && path[r+1] == '.' && (r+2 == n || isPathSeparator(path[r+2])):
// .. element: remove to last separator
r += 2
switch {
case out.w > dotdot:
// can backtrack
out.w--
for out.w > dotdot && !isPathSeparator(out.index(out.w)) {
out.w--
}
case !rooted:
// cannot backtrack, but not rooted, so append .. element.
if out.w > 0 {
out.append(separator)
}
out.append('.')
out.append('.')
dotdot = out.w
}
default:
// real path element.
// add slash if needed
if rooted && out.w != 1 || !rooted && out.w != 0 {
out.append(separator)
}
// copy element
for ; r < n && !isPathSeparator(path[r]); r++ {
out.append(path[r])
}
}
}
// Turn empty string into "."
if out.w == 0 {
out.append('.')
}
return FromSlash(out.string())
}
func isPathSeparator(c uint8) bool {
return c == '/'
}
func FromSlash(path string) string {
if separator == '/' {
return path
}
return replaceStringByte(path, '/', separator)
}
func replaceStringByte(s string, old, new byte) string {
if strings.IndexByte(s, old) == -1 {
return s
}
n := []byte(s)
for i := range n {
if n[i] == old {
n[i] = new
}
}
return string(n)
}
// A lazybuf is a lazily constructed path buffer.
// It supports append, reading previously appended bytes,
// and retrieving the final string. It does not allocate a buffer
// to hold the output until that output diverges from s.
type lazybuf struct {
path string
buf []byte
w int
volAndPath string
volLen int
}
func (b *lazybuf) index(i int) byte {
if b.buf != nil {
return b.buf[i]
}
return b.path[i]
}
func (b *lazybuf) append(c byte) {
if b.buf == nil {
if b.w < len(b.path) && b.path[b.w] == c {
b.w++
return
}
b.buf = make([]byte, len(b.path))
copy(b.buf, b.path[:b.w])
}
b.buf[b.w] = c
b.w++
}
func (b *lazybuf) string() string {
if b.buf == nil {
return b.volAndPath[:b.volLen+b.w]
}
return b.volAndPath[:b.volLen] + string(b.buf[:b.w])
}

64
s3api/utils/path_test.go Normal file
View File

@@ -0,0 +1,64 @@
// Copyright 2025 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils_test
import (
"testing"
"github.com/versity/versitygw/s3api/utils"
)
func TestIsObjectNameValid(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// valid names
{"simple file", "file.txt", true},
{"nested file", "dir/file.txt", true},
{"absolute nested file", "/dir/file.txt", true},
{"trailing slash", "dir/", true},
{"slash prefix", "/file.txt", true}, // treated as local after joined with bucket
{"dot slash prefix", "./file.txt", true},
// invalid names
{"dot dot only", "..", false},
{"dot only", ".", false},
{"dot slash", "./", false},
{"dot slash dot dot", "./..", false},
{"cleans to dot", "./../.", false},
{"empty", "", false},
{"file escapes 1", "../file.txt", false},
{"file escapes 2", "dir/../../file.txt", false},
{"file escapes 3", "../../../file.txt", false},
{"dir escapes 1", "../dir/", false},
{"dir escapes 2", "dir/../../dir/", false},
{"dir escapes 3", "../../../dir/", false},
{"dot escapes 1", "../.", false},
{"dot escapes 2", "dir/../../.", false},
{"dot escapes 3", "../../../.", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := utils.IsObjectNameValid(tt.input)
if got != tt.want {
t.Errorf("%v: IsObjectNameValid(%q) = %v, want %v",
tt.name, tt.input, got, tt.want)
}
})
}
}