Skip to content

Commit 3951087

Browse files
committed
fix(security): improve path traversal validation with comprehensive tests
Signed-off-by: Duncan Kibet <[email protected]>
1 parent a62e368 commit 3951087

File tree

2 files changed

+194
-42
lines changed

2 files changed

+194
-42
lines changed

util/security/path_traversal.go

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,48 @@ package security
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67
"strings"
78
)
89

9-
// Ensure that `requestedPath` is on the same directory or any subdirectory of `currentRoot`. Both `currentRoot` and
10-
// `requestedPath` must be absolute paths. They may contain any number of `./` or `/../` dir changes.
11-
func EnforceToCurrentRoot(currentRoot, requestedPath string) (string, error) {
12-
currentRoot = filepath.Clean(currentRoot)
13-
requestedDir, requestedFile := parsePath(requestedPath)
14-
if !isRequestedDirUnderCurrentRoot(currentRoot, requestedDir) {
15-
return "", fmt.Errorf("requested path %s should be on or under current directory %s", requestedPath, currentRoot)
10+
// EnforceToCurrentRoot opens the file securely, ensuring it stays within currentRoot.
11+
// It returns the open *os.File handle. The caller is responsible for closing the file.
12+
func EnforceToCurrentRoot(currentRoot, requestedPath string) (*os.File, error) {
13+
// 1. Create the secure jail (os.Root).
14+
// os.OpenRoot guarantees that any operations performed on 'root' are confined to that directory.
15+
root, err := os.OpenRoot(currentRoot)
16+
if err != nil {
17+
return nil, fmt.Errorf("failed to create root at %s: %w", currentRoot, err)
1618
}
17-
return requestedDir + string(filepath.Separator) + requestedFile, nil
18-
}
19+
// Closing the root handle does NOT close files opened from it.
20+
// We can safely close it before returning.
21+
defer root.Close()
1922

20-
func isRequestedDirUnderCurrentRoot(currentRoot, requestedPath string) bool {
21-
if currentRoot == string(filepath.Separator) {
22-
return true
23-
} else if currentRoot == requestedPath {
24-
return true
25-
}
26-
if requestedPath[len(requestedPath)-1] != '/' {
27-
requestedPath = requestedPath + "/"
23+
// 2. Normalize paths for the relative path calculation.
24+
// We use filepath.Clean on the string inputs ONLY to calculate the relative path
25+
// for the lookup. We do not use these strings for the actual OS-level access.
26+
cleanRoot := filepath.Clean(currentRoot)
27+
cleanPath := filepath.Clean(requestedPath)
28+
29+
relPath, err := filepath.Rel(cleanRoot, cleanPath)
30+
if err != nil {
31+
return nil, fmt.Errorf("requested path %s should be on or under current directory %s", requestedPath, currentRoot)
2832
}
29-
if currentRoot[len(currentRoot)-1] != '/' {
30-
currentRoot = currentRoot + "/"
33+
34+
// 3. Lexical Check (Defense in Depth).
35+
// Fast-fail if the path obviously tries to traverse upwards.
36+
if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
37+
return nil, fmt.Errorf("requested path %s should be on or under current directory %s", requestedPath, currentRoot)
3138
}
32-
return strings.HasPrefix(requestedPath, currentRoot)
33-
}
3439

35-
func parsePath(path string) (string, string) {
36-
directory := filepath.Dir(path)
37-
if directory == path {
38-
return directory, ""
40+
// 4. Secure Open
41+
// We open the file relative to the secure root handle.
42+
// If 'relPath' attempts to escape the root (even via symlinks), the OS will block it.
43+
f, err := root.Open(relPath)
44+
if err != nil {
45+
return nil, fmt.Errorf("access denied or file not found within root: %w", err)
3946
}
40-
return directory, filepath.Base(path)
47+
48+
return f, nil
4149
}
Lines changed: 160 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,171 @@
11
package security
22

33
import (
4+
"io"
5+
"os"
6+
"path/filepath"
7+
"runtime"
48
"testing"
59

610
"github.com/stretchr/testify/assert"
711
"github.com/stretchr/testify/require"
812
)
913

1014
func TestEnforceToCurrentRoot(t *testing.T) {
11-
cleanDir, err := EnforceToCurrentRoot("/home/argo/helmapp/", "/home/argo/helmapp/values.yaml")
12-
require.NoError(t, err)
13-
assert.Equal(t, "/home/argo/helmapp/values.yaml", cleanDir)
14-
15-
// File is outside current working directory
16-
_, err = EnforceToCurrentRoot("/home/argo/helmapp/", "/home/values.yaml")
17-
require.Error(t, err)
18-
19-
// File is outside current working directory
20-
_, err = EnforceToCurrentRoot("/home/argo/helmapp/", "/home/argo/helmapp/../differentapp/values.yaml")
21-
require.Error(t, err)
22-
23-
// Goes back and forth, but still legal
24-
cleanDir, err = EnforceToCurrentRoot("/home/argo/helmapp/", "/home/argo/helmapp/../../argo/helmapp/values.yaml")
25-
require.NoError(t, err)
26-
assert.Equal(t, "/home/argo/helmapp/values.yaml", cleanDir)
15+
tmpDir := t.TempDir()
16+
17+
// Setup: Create files with specific content so we can verify we opened the right one.
18+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "values.yaml"), []byte("root-file"), 0o644))
19+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "charts"), 0o755))
20+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "charts", "values.yaml"), []byte("subdir-file"), 0o644))
21+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "a", "b", "c", "d"), 0o755))
22+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "a", "b", "c", "d", "values.yaml"), []byte("deep-file"), 0o644))
23+
24+
// Helper to verify we received a valid file handle pointing to the expected data.
25+
verifyFileContent := func(t *testing.T, f *os.File, expectedContent string) {
26+
t.Helper()
27+
defer f.Close()
28+
content, err := io.ReadAll(f)
29+
require.NoError(t, err)
30+
assert.Equal(t, expectedContent, string(content))
31+
}
32+
33+
t.Run("file directly in root", func(t *testing.T) {
34+
testFile := filepath.Join(tmpDir, "values.yaml")
35+
fileHandle, err := EnforceToCurrentRoot(tmpDir, testFile)
36+
require.NoError(t, err)
37+
require.NotNil(t, fileHandle)
38+
verifyFileContent(t, fileHandle, "root-file")
39+
})
40+
41+
t.Run("file in subdirectory", func(t *testing.T) {
42+
testFile := filepath.Join(tmpDir, "charts", "values.yaml")
43+
fileHandle, err := EnforceToCurrentRoot(tmpDir, testFile)
44+
require.NoError(t, err)
45+
require.NotNil(t, fileHandle)
46+
verifyFileContent(t, fileHandle, "subdir-file")
47+
})
48+
49+
t.Run("file outside current working directory", func(t *testing.T) {
50+
outsidePath := filepath.Join(filepath.Dir(tmpDir), "values.yaml")
51+
_, err := EnforceToCurrentRoot(tmpDir, outsidePath)
52+
require.Error(t, err)
53+
assert.Contains(t, err.Error(), "should be on or under current directory")
54+
})
55+
56+
t.Run("file escapes using parent directory notation", func(t *testing.T) {
57+
escapePath := filepath.Join(tmpDir, "..", "differentapp", "values.yaml")
58+
_, err := EnforceToCurrentRoot(tmpDir, escapePath)
59+
require.Error(t, err)
60+
assert.Contains(t, err.Error(), "should be on or under current directory")
61+
})
62+
63+
t.Run("goes back and forth but remains within root", func(t *testing.T) {
64+
complexPath := filepath.Join(tmpDir, "charts", "..", "values.yaml")
65+
fileHandle, err := EnforceToCurrentRoot(tmpDir, complexPath)
66+
require.NoError(t, err)
67+
require.NotNil(t, fileHandle)
68+
verifyFileContent(t, fileHandle, "root-file")
69+
})
70+
71+
t.Run("path equals root directory", func(t *testing.T) {
72+
// Opening the directory itself should succeed
73+
fileHandle, err := EnforceToCurrentRoot(tmpDir, tmpDir)
74+
require.NoError(t, err)
75+
require.NotNil(t, fileHandle)
76+
defer fileHandle.Close()
77+
78+
info, err := fileHandle.Stat()
79+
require.NoError(t, err)
80+
assert.True(t, info.IsDir())
81+
})
82+
83+
t.Run("complex path with multiple . and ..", func(t *testing.T) {
84+
complexPath := filepath.Join(tmpDir, ".", "charts", "..", "values.yaml")
85+
fileHandle, err := EnforceToCurrentRoot(tmpDir, complexPath)
86+
require.NoError(t, err)
87+
require.NotNil(t, fileHandle)
88+
verifyFileContent(t, fileHandle, "root-file")
89+
})
90+
91+
t.Run("root with trailing slash vs without", func(t *testing.T) {
92+
testFile := filepath.Join(tmpDir, "values.yaml")
93+
94+
// 1. With Slash.
95+
rootWithSlash := tmpDir + string(filepath.Separator)
96+
f1, err1 := EnforceToCurrentRoot(rootWithSlash, testFile)
97+
require.NoError(t, err1)
98+
verifyFileContent(t, f1, "root-file")
99+
100+
// 2. Without Slash.
101+
f2, err2 := EnforceToCurrentRoot(tmpDir, testFile)
102+
require.NoError(t, err2)
103+
verifyFileContent(t, f2, "root-file")
104+
})
105+
106+
t.Run("attempt to escape with multiple parent references", func(t *testing.T) {
107+
escapePath := filepath.Join(tmpDir, "..", "..", "..", "etc", "passwd")
108+
_, err := EnforceToCurrentRoot(tmpDir, escapePath)
109+
require.Error(t, err)
110+
})
111+
112+
t.Run("deep nested subdirectory", func(t *testing.T) {
113+
testFile := filepath.Join(tmpDir, "a", "b", "c", "d", "values.yaml")
114+
fileHandle, err := EnforceToCurrentRoot(tmpDir, testFile)
115+
require.NoError(t, err)
116+
require.NotNil(t, fileHandle)
117+
verifyFileContent(t, fileHandle, "deep-file")
118+
})
119+
}
120+
121+
func TestEnforceToCurrentRootEdgeCases(t *testing.T) {
122+
tmpDir := t.TempDir()
123+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "values.yaml"), []byte("test"), 0o644))
124+
125+
t.Run("empty paths", func(t *testing.T) {
126+
_, err := EnforceToCurrentRoot("", "/some/path")
127+
require.Error(t, err)
128+
})
129+
130+
t.Run("relative root path", func(t *testing.T) {
131+
// EnforceToCurrentRoot expects absolute paths for the root (usually)
132+
// os.OpenRoot might work with relative paths, but filepath.Rel logic depends on clean comparisons.
133+
_, err := EnforceToCurrentRoot("relative/path", "/absolute/path")
134+
require.Error(t, err)
135+
})
136+
137+
t.Run("path with double slashes", func(t *testing.T) {
138+
doubleSlashPath := tmpDir + string(filepath.Separator) + string(filepath.Separator) + "values.yaml"
139+
f, err := EnforceToCurrentRoot(tmpDir, doubleSlashPath)
140+
require.NoError(t, err)
141+
defer f.Close()
142+
})
143+
144+
t.Run("non-existent file in valid directory", func(t *testing.T) {
145+
nonExistentPath := filepath.Join(tmpDir, "does-not-exist.yaml")
146+
_, err := EnforceToCurrentRoot(tmpDir, nonExistentPath)
147+
// This should fail because root.Open() checks if file exists.
148+
require.Error(t, err)
149+
})
150+
}
151+
152+
func TestEnforceToCurrentRootWindowsPaths(t *testing.T) {
153+
if runtime.GOOS != "windows" {
154+
t.Skip("Skipping Windows-specific tests on non-Windows platform")
155+
}
156+
tmpDir := t.TempDir()
157+
testFile := filepath.Join(tmpDir, "values.yaml")
158+
require.NoError(t, os.WriteFile(testFile, []byte("test"), 0o644))
159+
160+
t.Run("windows path in root", func(t *testing.T) {
161+
f, err := EnforceToCurrentRoot(tmpDir, testFile)
162+
require.NoError(t, err)
163+
defer f.Close()
164+
})
165+
166+
t.Run("windows path escape attempt", func(t *testing.T) {
167+
escapePath := filepath.Join(tmpDir, "..", "..", "Windows", "System32", "config")
168+
_, err := EnforceToCurrentRoot(tmpDir, escapePath)
169+
require.Error(t, err)
170+
})
27171
}

0 commit comments

Comments
 (0)