Repository navigation
fix(bindings/localstorage): keep file operations inside rootPath with os.Root - #4676
Thanatos9404 wants to merge 3 commits into
Conversation
… os.Root The binding checked each request file name with securejoin and then reopened the returned path by name, so the check and the file access were separate path walks. Open rootPath as an os.Root for each operation and use its methods, so a name cannot resolve outside the root while the operation runs. Names keep their current mapping: "../a", "/a" and, on Windows, "C:\a" still refer to "a" inside the root. A symlink inside the root that is absolute or points outside it now makes the operation fail instead of being remapped under the root. The list walk now returns read errors, such as an unreadable subdirectory, instead of skipping them, and no longer calls IsDir on a nil FileInfo. Signed-off-by: Yashvardhan Thanvi <[email protected]>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4676 +/- ##
==========================================
+ Coverage 31.94% 40.48% +8.54%
==========================================
Files 353 354 +1
Lines 47723 36708 -11015
==========================================
- Hits 15243 14862 -381
+ Misses 31277 20438 -10839
- Partials 1203 1408 +205 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| func resolvePath(rootPath string, filename string) (absPath string, relPath string) { | ||
| filename = filename[len(filepath.VolumeName(filename)):] | ||
| sep := string(filepath.Separator) | ||
| relPath = strings.TrimLeft(filepath.Clean(sep+filename), sep) |
There was a problem hiding this comment.
On Windows, a fileName that starts with a separator and contains .. keeps the .. after this line. filepath.Clean reads \ + /../c.txt as the UNC volume \\..\c.txt, so nothing is collapsed and relPath ends up as ..\c.txt. Output from the Windows Clean code in internal/filepathlite for these inputs:
input "/../c.txt" Clean="\\..\c.txt" relPath="..\c.txt"
input "\..\..\x" Clean="\\..\..\x" relPath="..\..\x"
input "../c.txt" Clean="\c.txt" relPath="c.txt"
os.Root refuses the result with path escapes from parent, so there is no escape. The comment above this function (.. cannot move the name above the root) is not true on Windows, and absPath in the log and error messages points outside the root. The /d.txt test case passes only because it has no ...
Cleaning with package path avoids the volume rules:
relPath = filepath.FromSlash(strings.TrimPrefix(path.Clean("/"+filepath.ToSlash(filename)), "/"))Adding {name: "/../c.txt", rel: "c.txt"} to TestOperations covers it on both platforms.
There was a problem hiding this comment.
Fixed: names are now cleaned with package path, and TestOperations has /../f.txt, plus \..\..\g.txt on Windows. With Go 1.26.10 on Windows the previous code fails that case with mkdirat ..: path escapes from parent. Go 1.27 stops treating \.. as a volume (golang/go#58451), but go.mod still targets 1.26.
| files = append(files, filepath.Join(rootPath, name)) | ||
| continue | ||
| } | ||
| sub, err := walkPath(root, rootPath, name) |
There was a problem hiding this comment.
list now fails as a whole when one subdirectory cannot be read. The description says this is intended, so this is a request to reconsider rather than a bug report. A common case: rootPath is the mount point of an ext4 volume, which has a root-owned lost+found with mode 0700, and daprd runs as a non-root user. On main, list returns the readable files. With this change every call returns error listing files in the directory /data: openat lost+found: permission denied, and nothing in the binding lets the user get a listing. Reproduced with a 0000 subdirectory.
Suggestion: skip subdirectories that return fs.ErrPermission (with a debug log) and only fail when the requested directory itself cannot be read:
sub, err := walkPath(root, rootPath, name)
if errors.Is(err, fs.ErrPermission) {
ls.logger.Debugf("skipping unreadable directory %s", name)
continue
}
if err != nil {
return nil, err
}There was a problem hiding this comment.
Agreed. list now skips a subdirectory that fails with fs.ErrPermission (debug log) and only fails when the requested directory itself cannot be read. TestListSkipsUnreadableDirectory covers both.
|
|
||
| dir := filepath.Dir(absPath) | ||
| err = os.MkdirAll(dir, 0o777) | ||
| root, err := os.OpenRoot(ls.metadata.RootPath) |
There was a problem hiding this comment.
Opening the root by path on every operation means the checks in validateRootPath (symlink resolution and the disallowed path list) only hold at Init. If rootPath is later replaced by a symlink to /var/run/secrets, every create, get, list and delete follows it, because os.Root only protects paths below the directory it was opened from. This is the same on main, so not a regression, and the description explains the per-operation open is there to recreate a deleted root.
It is a trade-off worth a deliberate choice: open the os.Root once in Init, close it in Close, and give up recreating the root; or keep the current behaviour and note the limit in the comment above create. Either is fine, as long as it is a choice.
There was a problem hiding this comment.
I kept opening the root per operation, because the certification test removes rootPath between steps and expects create to recreate it. The limit is now documented on a small openRoot helper that create, delete and list use.
| absPath, relPath := resolvePath(ls.metadata.RootPath, filename) | ||
|
|
||
| f, err := os.Open(absPath) | ||
| f, err := os.OpenInRoot(ls.metadata.RootPath, relPath) |
There was a problem hiding this comment.
get and create still open FIFOs and device files inside the root. The os.Root docs say it does not prohibit "/proc special files, or access to Unix device files". Anyone who can write inside rootPath can run mkfifo f; get on f then blocks in OpenInRoot until a writer appears, create blocks in root.Create until a reader appears, and Invoke ignores its context, so each request leaks a goroutine. Same on main, so this is optional hardening.
If you want to close it here: open with root.OpenFile(relPath, os.O_RDONLY|syscall.O_NONBLOCK, 0), then reject the file when fi.Mode().IsRegular() is false. On a FIFO the non-blocking open returns at once, and O_WRONLY|O_NONBLOCK fails with ENXIO instead of blocking.
syscall.O_NONBLOCK is also defined on Windows, so this compiles there.
There was a problem hiding this comment.
Done. get opens with O_RDONLY|O_NONBLOCK and create with O_WRONLY|O_CREATE|O_TRUNC|O_NONBLOCK, and both reject anything that is not a regular file. TestFIFOIsRejected (Linux and macOS) fails on the previous commit because get blocks.
| require.NoError(t, os.WriteFile(filepath.Join(outside, "file.txt"), []byte("outside"), 0o600)) | ||
|
|
||
| ls := newTestLocalStorage(t) | ||
| require.NoError(t, os.Symlink(outside, filepath.Join(ls.metadata.RootPath, "link"))) |
There was a problem hiding this comment.
The test covers an absolute link only. The relative case (esc -> ../../etc) is the one os.Root was introduced for, and a later change back to path-based access would not fail this test. Worth adding here: a relative escaping link for create, get, delete and list, and list of the root while an escaping link is present. Today that returns the link as a file entry, which is reasonable but not asserted anywhere.
There was a problem hiding this comment.
Added a relative link next to the absolute one for create, get, delete and list, and the test now asserts that listing the root returns both links as entries.
| // under rootPath, in lexical order. It reads directories through root directly | ||
| // because root.FS() rejects names that are not valid UTF-8. | ||
| func walkPath(root *os.Root, rootPath string, dir string) ([]string, error) { | ||
| d, err := root.Open(dir) |
There was a problem hiding this comment.
Each root.Open(dir) here takes the full relative path, so os.Root resolves every component from the root again for every directory in the tree (os.Root on Linux does not use openat2). For a tree N directories wide and D levels deep that is about N*D openat calls instead of N. Recursing with a child root avoids it and also pins each directory, so a subdirectory swapped for a symlink between ReadDir and the next Open is not followed:
func walkPath(root *os.Root, prefix string) ([]string, error) {
d, err := root.Open(".")
...
for _, e := range entries {
name := filepath.Join(prefix, e.Name())
if !e.IsDir() {
files = append(files, name)
continue
}
sub, err := root.OpenRoot(e.Name())
if err != nil {
return nil, err
}
got, err := walkPath(sub, name)
sub.Close()
...
}
}with list calling root.OpenRoot(relPath) once and passing absPath as the prefix. Minor; fine to leave.
There was a problem hiding this comment.
Done, the walk now opens each subdirectory as a child root and passes the absolute prefix down.
| root, err := os.OpenRoot(ls.metadata.RootPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error getting absolute path for file %s: %w", filename, err) | ||
| return nil, fmt.Errorf("error getting stats for path %s: %w", absPath, err) |
There was a problem hiding this comment.
When os.OpenRoot fails here the error says error getting stats for path <file>, and in delete (line 253) it says error deleting file <file>. Both name the requested file, not the root directory that could not be opened. If rootPath is removed after Init, the operator is sent to look at the wrong path. create already uses error opening directory %s with ls.metadata.RootPath; the same message fits here and in delete.
There was a problem hiding this comment.
list and delete now return error opening directory <rootPath>, like create. get keeps file not found: <file> when the root is missing, because the certification test removes the root and then expects that message.
…-root Signed-off-by: Yashvardhan Thanvi <[email protected]>
Clean request names with package path, so a name such as "/../c.txt" maps to "c.txt" on Windows too. filepath.Clean in Go 1.26 reads it as a UNC volume and keeps the "..". Skip subdirectories that cannot be read because of their permissions when listing, such as a root-owned lost+found, and only fail when the requested directory itself cannot be read. Walk the tree through child roots, so each directory is resolved once. Open files for get and create with O_NONBLOCK and reject anything that is not a regular file, so a FIFO in the root cannot block a request. Name the root directory when it cannot be opened, and document that it is opened per operation so create can recreate it, which means the rootPath checks only hold at Init. Test a relative escaping link, listing the root with escaping links, an unreadable subdirectory and a FIFO. Signed-off-by: Yashvardhan Thanvi <[email protected]>
|
Thanks for the detailed review. I merged main, which also brings the Go version the Linux build now needs, and addressed each point in e0db823; replies are on the threads. |
Description
The local storage binding checks each request's
fileNamewithsecurejoin.SecureJoinand then reopens the returned path by name for every operation. The check and the file access are two separate path walks, so if another process changes a directory insiderootPathin between, the path that gets used is not the one that was checked.This change opens
rootPathas anos.Rootfor each operation and does all file access through it, including the list walk. Names keep their current mapping, so../a,/aand, on Windows,C:\astill meanainside the root. The root is opened per operation, socreatestill recreates the directory if it was removed afterInit.Behaviour changes: a symlink inside the root that is absolute or points outside it now makes the request fail instead of being remapped under the root (relative links that stay inside still work).
getandcreatenow refuse anything that is not a regular file, so a FIFO in the root cannot block a request.liststill skips subdirectories it cannot read and no longer callsIsDiron a nilFileInfo. As before, therootPathchecks only apply atInit, which is noted in the code.Tests cover create/get/list/delete (including
/../anames on Windows), absolute and relative escaping symlinks, an unreadable subdirectory and a FIFO. All pass on Linux; on Windows the tests that do not create symlinks pass.Issue reference
There is no issue for this one. It is a small hardening change, and I am happy to open an issue first if you prefer.
Checklist
Please make sure you've completed the relevant tasks for this PR, out of the following list: