Skip to content

Commit 1525370

Browse files
authored
Fix prefix-boundary hole in filer root-escape checks (#5497)
## Why Found during a full-repo review of the CLI. All filers guard against relative paths escaping their root, but the guard in `libs/filer/workspace_root_path.go` and `libs/filer/local_root_path.go` was a bare string prefix check. A path that resolves to a sibling directory sharing the root as a name prefix slipped through: with root `/Users/me/proj`, joining `../proj-evil/x` resolves to `/Users/me/proj-evil/x`, which passes `strings.HasPrefix`. Every filer (workspace files, DBFS, UC volumes, local) shares these helpers, and `bundle init` templates write template-author-controlled paths through them. ## Changes Before, a joined path only had to start with the root string; now it must either be exactly the root or extend it past a separator boundary. - `WorkspaceRootPath.Join` and `localRootPath.Join` compare against the root with a trailing separator and explicitly allow the exact-root result. Joins like `ReadDir(".")` resolve to exactly the root and keep working. - Filers rooted at `/` (used by the `fs` commands) keep working: the separator is only appended when the cleaned root does not already end in one. Same for Windows drive roots like `C:\`. - The unrooted local filer (`NewLocalRootPath("")`, used by `fs` for local paths) keeps accepting any path. ## Test plan - [x] Added sibling-prefix escape cases (`../path-evil`, `../path-evil/x`, `../pathx`) to `libs/filer/workspace_root_path_test.go` and `libs/filer/local_root_path_test.go` (Unix and Windows variants); these fail without the fix - [x] Added escape-and-re-enter cases asserting `../path/x` still joins under the root - [x] Added `TestLocalRootPathEmptyRoot` covering the unrooted local filer passthrough - [x] Existing exact-root cases (`Join("")`, `Join(".")`, `Join("/")`) pass unchanged - [x] `go test ./libs/filer/` passes - [x] `go test ./libs/template/... ./libs/sync/...` (direct consumers of these helpers) passes - [x] `./task fmt-q`, `./task lint-q`, `./task checks` pass This pull request and its description were written by Isaac.
1 parent 5d85057 commit 1525370

4 files changed

Lines changed: 82 additions & 2 deletions

File tree

libs/filer/local_root_path.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,21 @@ func NewLocalRootPath(root string) localRootPath {
1919

2020
func (rp *localRootPath) Join(name string) (string, error) {
2121
absPath := filepath.Join(rp.rootPath, name)
22-
if !strings.HasPrefix(absPath, rp.rootPath) {
22+
23+
// An empty root carries no restriction (cmd/fs constructs unrooted local filers).
24+
if rp.rootPath == "" {
25+
return absPath, nil
26+
}
27+
28+
// Joining exactly the root must stay allowed: calls like ReadDir(".") resolve to it.
29+
// Any other path must extend the root past a separator boundary; a plain prefix
30+
// check would also accept siblings like "/root-evil" for root "/root".
31+
// Cleaned roots like "/" or `C:\` already end in a separator.
32+
root := rp.rootPath
33+
if !strings.HasSuffix(root, string(filepath.Separator)) {
34+
root += string(filepath.Separator)
35+
}
36+
if absPath != rp.rootPath && !strings.HasPrefix(absPath, root) {
2337
return "", fmt.Errorf("relative path escapes root: %s", name)
2438
}
2539
return absPath, nil

libs/filer/local_root_path_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ func testUnixLocalRootPath(t *testing.T, uncleanRoot string) {
5252
assert.NoError(t, err)
5353
assert.Equal(t, cleanRoot, remotePath)
5454

55+
// All roots in this test end in the path element "path".
56+
remotePath, err = rp.Join("../path/x")
57+
assert.NoError(t, err)
58+
assert.Equal(t, cleanRoot+"/x", remotePath)
59+
5560
_, err = rp.Join("..")
5661
assert.ErrorContains(t, err, `relative path escapes root: ..`)
5762

@@ -78,6 +83,16 @@ func testUnixLocalRootPath(t *testing.T, uncleanRoot string) {
7883

7984
_, err = rp.Join("../..")
8085
assert.ErrorContains(t, err, `relative path escapes root: ../..`)
86+
87+
// Siblings that share the root as a string prefix must be rejected.
88+
_, err = rp.Join("../path-evil")
89+
assert.ErrorContains(t, err, `relative path escapes root: ../path-evil`)
90+
91+
_, err = rp.Join("../path-evil/x")
92+
assert.ErrorContains(t, err, `relative path escapes root: ../path-evil/x`)
93+
94+
_, err = rp.Join("../pathx")
95+
assert.ErrorContains(t, err, `relative path escapes root: ../pathx`)
8196
}
8297

8398
func TestUnixLocalRootPath(t *testing.T) {
@@ -128,6 +143,21 @@ func testWindowsLocalRootPath(t *testing.T, uncleanRoot string) {
128143

129144
_, err = rp.Join(`a\..\..`)
130145
assert.ErrorContains(t, err, `relative path escapes root`)
146+
147+
// All roots in this test end in the path element "path".
148+
remotePath, err = rp.Join(`..\path\x`)
149+
assert.NoError(t, err)
150+
assert.Equal(t, cleanRoot+`\x`, remotePath)
151+
152+
// Siblings that share the root as a string prefix must be rejected.
153+
_, err = rp.Join(`..\path-evil`)
154+
assert.ErrorContains(t, err, `relative path escapes root`)
155+
156+
_, err = rp.Join(`..\path-evil\x`)
157+
assert.ErrorContains(t, err, `relative path escapes root`)
158+
159+
_, err = rp.Join(`..\pathx`)
160+
assert.ErrorContains(t, err, `relative path escapes root`)
131161
}
132162

133163
func TestWindowsLocalRootPath(t *testing.T) {
@@ -140,3 +170,16 @@ func TestWindowsLocalRootPath(t *testing.T) {
140170
testWindowsLocalRootPath(t, `c:\some\root\path\.`)
141171
testWindowsLocalRootPath(t, `C:\some\root\..\path\`)
142172
}
173+
174+
func TestLocalRootPathEmptyRoot(t *testing.T) {
175+
// An empty root is the unrooted local filer used by cmd/fs.
176+
rp := NewLocalRootPath("")
177+
178+
p, err := rp.Join("a/b")
179+
assert.NoError(t, err)
180+
assert.Equal(t, filepath.Clean("a/b"), p)
181+
182+
p, err = rp.Join("../a")
183+
assert.NoError(t, err)
184+
assert.Equal(t, filepath.Clean("../a"), p)
185+
}

libs/filer/workspace_root_path.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ func (p *WorkspaceRootPath) Join(name string) (string, error) {
2626
absPath := path.Join(p.rootPath, name)
2727

2828
// Don't allow escaping the specified root using relative paths.
29-
if !strings.HasPrefix(absPath, p.rootPath) {
29+
// Joining exactly the root must stay allowed: calls like ReadDir(".") resolve to it.
30+
// Any other path must extend the root past a separator boundary; a plain prefix
31+
// check would also accept siblings like "/root-evil" for root "/root".
32+
// Cleaned roots like "/" (see cmd/fs) already end in a separator.
33+
root := p.rootPath
34+
if !strings.HasSuffix(root, "/") {
35+
root += "/"
36+
}
37+
if absPath != p.rootPath && !strings.HasPrefix(absPath, root) {
3038
return "", fmt.Errorf("relative path escapes root: %s", name)
3139
}
3240

libs/filer/workspace_root_path_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ func testRootPath(t *testing.T, uncleanRoot string) {
5151
assert.NoError(t, err)
5252
assert.Equal(t, cleanRoot, remotePath)
5353

54+
// All roots in this test end in the path element "path".
55+
remotePath, err = rp.Join("../path/x")
56+
assert.NoError(t, err)
57+
assert.Equal(t, cleanRoot+"/x", remotePath)
58+
5459
_, err = rp.Join("..")
5560
assert.ErrorContains(t, err, `relative path escapes root: ..`)
5661

@@ -77,6 +82,16 @@ func testRootPath(t *testing.T, uncleanRoot string) {
7782

7883
_, err = rp.Join("../..")
7984
assert.ErrorContains(t, err, `relative path escapes root: ../..`)
85+
86+
// Siblings that share the root as a string prefix must be rejected.
87+
_, err = rp.Join("../path-evil")
88+
assert.ErrorContains(t, err, `relative path escapes root: ../path-evil`)
89+
90+
_, err = rp.Join("../path-evil/x")
91+
assert.ErrorContains(t, err, `relative path escapes root: ../path-evil/x`)
92+
93+
_, err = rp.Join("../pathx")
94+
assert.ErrorContains(t, err, `relative path escapes root: ../pathx`)
8095
}
8196

8297
func TestRootPathClean(t *testing.T) {

0 commit comments

Comments
 (0)