Skip to content

fix(bindings/localstorage): keep file operations inside rootPath with os.Root - #4676

Open
Thanatos9404 wants to merge 3 commits into
dapr:mainfrom
Thanatos9404:fix/localstorage-os-root
Open

Thanatos9404 wants to merge 3 commits into
dapr:mainfrom
Thanatos9404:fix/localstorage-os-root

Conversation

@Thanatos9404

@Thanatos9404 Thanatos9404 commented Oct 19, 2026 •

Copy link
Copy Markdown

Description

The local storage binding checks each request's fileName with securejoin.SecureJoin and 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 inside rootPath in between, the path that gets used is not the one that was checked.

This change opens rootPath as an os.Root for each operation and does all file access through it, including the list walk. Names keep their current mapping, so ../a, /a and, on Windows, C:\a still mean a inside the root. The root is opened per operation, so create still recreates the directory if it was removed after Init.

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). get and create now refuse anything that is not a regular file, so a FIFO in the root cannot block a request. list still skips subdirectories it cannot read and no longer calls IsDir on a nil FileInfo. As before, the rootPath checks only apply at Init, which is noted in the code.

Tests cover create/get/list/delete (including /../a names 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:

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation
    • Created the dapr/docs PR: probably not needed, the binding page does not describe how names or symlinks are resolved

… 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]>
@Thanatos9404
Thanatos9404 requested review from a team as code owners October 19, 2026 16:05
Copilot AI balanced review requested due to automatic review settings October 19, 2026 16:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Nov 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.00000% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 40.48%. Comparing base (cc03682) to head (86400bd).
⚠️ Report is 103 commits behind head on main.

Files with missing lines Patch % Lines
bindings/localstorage/localstorage.go 84.00% 7 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread bindings/localstorage/localstorage.go Outdated
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bindings/localstorage/localstorage.go Outdated
files = append(files, filepath.Join(rootPath, name))
continue
}
sub, err := walkPath(root, rootPath, name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bindings/localstorage/localstorage.go Outdated

dir := filepath.Dir(absPath)
err = os.MkdirAll(dir, 0o777)
root, err := os.OpenRoot(ls.metadata.RootPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bindings/localstorage/localstorage.go Outdated
absPath, relPath := resolvePath(ls.metadata.RootPath, filename)

f, err := os.Open(absPath)
f, err := os.OpenInRoot(ls.metadata.RootPath, relPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bindings/localstorage/localstorage.go Outdated
// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the walk now opens each subdirectory as a child root and passes the absolute prefix down.

Comment thread bindings/localstorage/localstorage.go Outdated
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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]>
@Thanatos9404

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants