Lossy username normalization for home-directory derivation lets a distinct signup account share a victim's home directory
high Fully Valid high confidence
Status
Fully Valid
Canonical record. Every cited line verified, and the sole flagged assumption (storm byte-exact uniqueness) was confirmed in dependency source (storm index/unique.go). The isolation-bypass path is complete and deterministic given the supported Signup+CreateUserDir configuration, so fully_valid; the non-default config affects likelihood, captured in preconditions/severity.
Repository / Component
Plain-English Description
Usernames are stored exactly as typed, but the folder for each user is named after a cleaned-up version of the username. Two different usernames can clean to the same folder name, so someone can register a look-alike name and end up sharing another person's private files.
Description of the Underlying Issue
When Scope is empty and CreateUserDir is on, settings/dir.go:22-40 MakeUserDir derives userScope = path.Join(UserHomeBasePath, cleanUsername(username)) and calls MkdirAll idempotently (no error if the directory already exists). cleanUsername (settings/dir.go:42-53) is lossy: it trims spaces, deletes '..', maps any char outside [0-9A-Za-z@_-.] to '-', and collapses runs of dashes — so 'alice', 'alice ', and 'al..ice' all become 'alice', and 'a b'/'a/b'/'a--b' all become 'a-b'. Stored username uniqueness, however, is byte-exact: users/users.go:24 marks Username storm:"unique", users/users.go:59-100 Clean only rejects an empty username and never normalizes it, and storage/bolt/users.go:77-83 returns ErrExist only on storm.ErrAlreadyExists. The storm unique index (github.com/asdine/storm/v3 index/unique.go:37-54) keys the bbolt bucket on the exact marshaled value, so byte-distinct usernames never collide. Signup (http/auth.go:164-166) stores the raw untrimmed username, forces Scope="", calls MakeUserDir(rawUsername,...) and sets user.Scope=userHome (:186-198). Because users/users.go:93-96 sets Fs=BasePathFs(OsFs, join(baseScope,Scope)), an identical Scope yields an identical confinement root — two distinct accounts sharing one directory tree. Proxy auto-provisioning (auth/proxy.go:22,43-60) is a secondary vector using the header-supplied username.
Potential Attack
Victim 'alice' has home /users/alice. The attacker POSTs /api/signup {username:'alice ', password:...}. auth.go stores 'alice ' (byte-distinct from 'alice', so storm's unique index accepts it), forces Scope='', and MakeUserDir('alice ',...) computes cleanUsername='alice' → Scope=/users/alice; MkdirAll succeeds against the pre-existing directory. The attacker's account now has BasePathFs root == /users/alice while retaining its own credentials and audit identity. With default Defaults.Perm (Create/Rename/Modify/Delete/Download) the attacker has full read-write-delete over alice's files.
Outcomes of Potential Attack
Cross-account filesystem access: the attacker reads, modifies, and deletes the victim's files while operating under a separate login and audit identity. This defeats per-user home-directory isolation and can be used for data theft, tampering, or planting content (including, combined with other findings, hook triggers) inside the victim's scope.
Affected Scope
Per-user home-directory confinement for all self-signup and proxy-provisioned users when CreateUserDir=true. Fix site settings/dir.go MakeUserDir with coordinated username-uniqueness normalization in users/http/auth.
Suggested Fix (plain english)
Name each user's folder from something that is guaranteed unique, such as the user's numeric ID, or require usernames to be unique after the same cleaning that names the folder.
Suggested Fix (detailed)
Derive the per-user home directory from the immutable numeric user ID (users.go:23) or a random slug instead of cleanUsername(username); OR normalize+validate the STORED username to the same charset as cleanUsername and enforce uniqueness AFTER normalization so uniqueness and directory derivation agree; OR in MakeUserDir/signup reject creation when the derived userScope already exists or is assigned to another user. Implement in settings/dir.go with a coordinated username-validation change in users/http/auth. Verify by attempting to register 'alice ' while 'alice' exists and confirming either rejection or a distinct, non-shared home directory. Supersedes the narrower DOK-100071. Derive the per-user home directory from the immutable numeric user ID (users.go:23) or a random slug instead of cleanUsername(username); OR normalize+validate the STORED username to the same charset as cleanUsername and enforce uniqueness AFTER normalization so uniqueness and directory derivation agree; OR in MakeUserDir/signup reject creation when the derived userScope already exists or is assigned to another user. Fix in settings/dir.go with a coordinated username-validation change.
Validation
Canonical record. Every cited line verified, and the sole flagged assumption (storm byte-exact uniqueness) was confirmed in dependency source (storm index/unique.go). The isolation-bypass path is complete and deterministic given the supported Signup+CreateUserDir configuration, so fully_valid; the non-default config affects likelihood, captured in preconditions/severity.
Full Evidence
settings/dir.go:42-53 — cleanUsername is lossy: Trim(' '), ReplaceAll('..',''), [^0-9A-Za-z@_\-.]->'-', collapse dashes; distinct inputs 'alice'/'alice '/'al..ice' -> 'alice', 'a b'/'a/b'/'a--b'/'a-b' -> 'a-b'settings/dir.go:22-40 — MakeUserDir derives userScope=path.Join(UserHomeBasePath, cleanUsername(username)) when Scope empty + CreateUserDir, then idempotent MkdirAll (no error on a pre-existing victim dir)
users/users.go:24 Username `storm:"unique"` (byte-exact); users/users.go:59-100 Clean checks Username only for emptiness and never trims/normalizes it; users/users.go:93-96 u.Fs=BasePathFs(OsFs, join(baseScope,Scope)) so identical Scope => identical confinement root
storage/bolt/users.go:77-83 Save returns ErrExist only on storm.ErrAlreadyExists; github.com/asdine/storm/v3 index/unique.go:37-54 keys the bbolt bucket on the exact marshaled value, so byte-distinct usernames never collide
http/auth.go:164-166 signup stores raw info.Username; :186-190 forces Scope="" then MakeUserDir(rawUsername,...); :195 sets user.Scope=userHome; :198 Save with raw username
auth/proxy.go:22,43-60 proxy auto-provision uses the same MakeUserDir path with the header-supplied username (secondary vector)
cmd/root.go:398,400 Signup/CreateUserDir default false; cmd/root.go default Defaults.Perm grants Create/Rename/Modify/Delete/Download (full read-write takeover of the shared home)
Proven fact: cleanUsername is many-to-one while stored username uniqueness is byte-exact; a user who registers a normalization-variant of a victim's username (e.g. trailing space) is a distinct account whose derived Scope equals the victim's home path.
Proven fact: Identical Scope produces an identical afero BasePathFs root (users.go:93-96), so both accounts read/write the same directory tree; MkdirAll idempotency means the second registration does not fail or wipe the victim dir.
Proven fact: storm's unique index is byte-exact (index/unique.go Add uses bbolt Get/Put on the exact value), confirming the two byte-distinct usernames coexist — this resolves the only assumption the candidate flagged.
Proven fact: Signup accepts an untrimmed username (auth.go:160 only rejects empty), so the colliding variant is directly attacker-suppliable; the resulting attacker account keeps its own credentials/audit identity while sharing the victim's storage.
Proven fact: With default Defaults.Perm (Create/Modify/Delete/Download) the cross-account access is full read-write-delete of the victim's files.
Unvalidated fact: Whether a specific deployment has enabled Signup=true (or proxy auto-provisioning) together with CreateUserDir=true — a supported multi-user self-service + isolated-homes configuration, but off by default. This is a config toggle, not a reachability gap: once enabled the collision is deterministic.
Unvalidated fact: Attacker must know/guess the victim's exact username to craft a colliding variant (usernames are identities, not secrets).
settings/dir.go:42-53 — cleanUsername is lossy: Trim(' '), ReplaceAll('..',''), [^0-9A-Za-z@_-.]->'-', collapse dashes; 'alice'/'alice '/'al..ice'->'alice', 'a b'/'a/b'/'a--b'/'a-b'->'a-b'users/users.go:24 — Username storm:"unique" (byte-exact); users/users.go:59-100 Clean checks only emptiness and never normalizes; users/users.go:93-96 Fs=BasePathFs(OsFs, join(baseScope,Scope)) so identical Scope => identical confinement root
storage/bolt/users.go:77-83 — Save returns ErrExist only on storm.ErrAlreadyExists; asdine/storm/v3 index/unique.go:37-54 keys the bucket on the exact marshaled value, so byte-distinct usernames never collide
http/auth.go:164-166 — signup stores raw info.Username; :186-190 forces Scope="" then MakeUserDir(rawUsername,...); :195 sets user.Scope=userHome; :198 Save with raw username
auth/proxy.go:22,43-60 — proxy auto-provision uses the same MakeUserDir path with the header-supplied username (secondary vector)
cmd/root.go:398,400 — Signup/CreateUserDir default false; Defaults.Perm grants Create/Rename/Modify/Delete/Download
Proven fact: cleanUsername is many-to-one while stored username uniqueness is byte-exact; registering a normalization-variant of a victim's username (e.g. trailing space) yields a distinct account whose derived Scope equals the victim's home path.
Proven fact: Identical Scope produces an identical afero BasePathFs root (users.go:93-96), so both accounts read/write the same tree; MkdirAll idempotency means the second registration neither fails nor wipes the victim dir.
Proven fact: storm's unique index is byte-exact (index/unique.go Add uses bbolt Get/Put on the exact value), confirming the two byte-distinct usernames coexist.
Proven fact: Signup accepts an untrimmed username (auth.go:160 only rejects empty), so the colliding variant is directly attacker-suppliable; the attacker account keeps its own credentials/audit identity while sharing the victim's storage.
Proven fact: With default Defaults.Perm the cross-account access is full read-write-delete of the victim's files.
Unvalidated fact: Whether a specific deployment has enabled Signup=true (or proxy auto-provisioning) together with CreateUserDir=true — a supported but non-default configuration. Once enabled the collision is deterministic.
Unvalidated fact: Attacker must know or guess the victim's exact username to craft a colliding variant (usernames are identities, not secrets).
settings/dir.go:22-40 (MakeUserDir; scope from cleanUsername at :25,30; idempotent MkdirAll at :36)
settings/dir.go:42-53 (cleanUsername lossy normalization: Trim spaces, strip '..', invalid->'-', collapse dashes)
users/users.go:24 (Username storm:"unique", byte-exact) and users/users.go:59-100 (Clean never trims Username) and users/users.go:93-97 (BasePathFs built from Scope)
storage/bolt/users.go:77-83 (db.Save -> ErrExist only on exact duplicate) and :19-42 (GetBy exact match)
http/auth.go:186-198 (signup forces Scope="" then MakeUserDir then Save with raw username)
auth/proxy.go:48-58 (proxy auto-provision uses the same MakeUserDir path with header-supplied username)
cmd/root.go:398,400 (Signup/CreateUserDir default false; attack requires the standard multi-user self-service config)