Skip to content

fix(cloud-storage): resolve symlinks when enforcing local path boundaries - #3810

Open
huangjiahua wants to merge 1 commit into
googleapis:mainfrom
huangjiahua:fix/cloudstorage-symlink-escape
Open

fix(cloud-storage): resolve symlinks when enforcing local path boundaries#3810
huangjiahua wants to merge 1 commit into
googleapis:mainfrom
huangjiahua:fix/cloudstorage-symlink-escape

Conversation

@huangjiahua

Copy link
Copy Markdown
Contributor

Description

allowedLocalRoots (source) and destination_dir (the download tool) both decided whether a local path was in bounds by inspecting the path string, then handed the unresolved name to os.Open/os.OpenFile. Neither resolved symbolic links, so a link placed under an allowed root passed the check and opened its target wherever that was:

  • cloud-storage-upload-object reads a file outside the root and sends it to Cloud Storage.
  • cloud-storage-download-object writes through the link to a file outside the root.

The write escape does not require overwrite=true. O_CREATE|O_EXCL does reject a symlinked final component, but an intermediate directory link — root/dir -> /elsewhere — still lands the file outside the root under the default overwrite=false.

Solution

New cloudstoragecommon.ResolveSymlinks returns a path's final filesystem target, handling two cases a bare filepath.EvalSymlinks does not:

  • Nonexistent leaves. EvalSymlinks returns ENOENT for a path that doesn't exist yet, which is the normal case for a download destination. It resolves the deepest existing ancestor and reattaches the missing components literally.
  • Dangling links. These also return ENOENT, and treating one as "just a missing file" would reopen the escape, since a later create follows the link. Rejected instead.

Both boundaries now compare the resolved target in addition to the name:

  • Source.validateLocalPath requires the path to be under an allowed root both as written and after resolution. Keeping the name check first means rejection messages still name what the caller asked for, and guarantees the new behavior is never looser than the old.
  • ResolveWithinDir gets the same treatment for destination_dir, which had the identical flaw.

Roots are resolved too, so a root reached through a link (/tmp on macOS, a symlinked workspace) keeps matching, and links resolving back inside a root keep working.

Known limits (documented, not fixed)

  • Hard links are indistinguishable from regular files to any resolver.
  • TOCTOU: the check still precedes the open, so anything able to write into an allowed root can swap a component in between. Closing this would mean moving enforcement to os.Root/openat2 at the point of I/O — a larger change; happy to follow up if reviewers prefer it here.

source.md now states the guarantee and both limits, and keeps the guidance that OS permissions are the enforcing boundary.

Behavior change

A workspace that legitimately symlinks out of an allowed root is now rejected. That is the intent of the fix, but it may surface as a bug report.

PR Checklist

  • Make sure you reviewed CONTRIBUTING.md
  • Make sure to open an issue as a bug/issue before writing your code! — this came from an external security report rather than a filed issue; happy to open one if that is preferred
  • Ensure the tests and linter pass — full unit suite (./cmd/... ./internal/...), go vet, and gofmt pass. golangci-lint was not available locally and has not been run
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)
  • Make sure to add ! if this involve a breaking change — tightens validation rather than changing an API, so filed as fix; see "Behavior change" above

Testing

Each fix was reverted in turn to confirm the new tests fail without it. TestAllowedLocalRootSymlinkEscape reproduces the reported scenario against the unpatched source:

--- FAIL: TestAllowedLocalRootSymlinkEscape
    validateLocalPath(".../allowed/innocent.txt") accepted a path escaping
    ".../allowed"; it reads "OUTSIDE_SECRET" from ".../outside/secret.txt"

All four destination_dir escape cases likewise fail against the unpatched ResolveWithinDir. Coverage added for: escaping link leaf, escaping intermediate directory link, new file under an escaping directory link, dangling link, links resolving back inside the root, roots reached through a link, and unset allowedLocalRoots remaining unrestricted — across both boundaries.

🛠️ Fixes #<issue_number_goes_here>

…ries

allowedLocalRoots and the download tool's destination_dir both decided
whether a path was in bounds by inspecting the path string, then handed the
unresolved name to os.Open/os.OpenFile. A symbolic link planted under an
allowed root therefore passed the check and opened its target, wherever that
was: upload_object would read a file outside the root and send it to Cloud
Storage, and download_object would write through the link.

The write escape did not require overwrite=true. O_CREATE|O_EXCL does reject
a symlinked final component, but an intermediate directory link -- root/dir
-> /elsewhere -- still lands the file outside the root under the default
overwrite=false.

Both boundaries now compare the symlink-resolved target in addition to the
name. Roots are resolved as well, so a root reached through a link (/tmp on
macOS, a symlinked workspace) keeps matching, and links resolving back inside
a root keep working. Destinations that do not exist yet resolve as deeply as
the filesystem allows; a dangling link is rejected rather than treated as a
missing name, since creating that path would follow it.

Two limits are documented rather than fixed: hard links are indistinguishable
from regular files, and the check still precedes the open, so anything able to
write into an allowed root can swap a component in between. OS permissions
remain the enforcing boundary.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces robust symbolic link resolution and validation for local filesystem access within the cloud storage source. It updates validateLocalPath and ResolveWithinDir to resolve symlinks for both the input paths and the allowed root directories before performing boundary checks, preventing potential directory traversal bypasses via symlinks. Comprehensive unit tests and documentation updates have been added to cover these changes. No review comments were provided, so there is no feedback to address.

@huangjiahua
huangjiahua marked this pull request as ready for review August 12, 2026 16:46
@huangjiahua
huangjiahua requested a review from a team as a code owner August 12, 2026 16:46
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.

2 participants