Unbounded req.Which field list in userPutHandler amplifies one request into N fsync'd DB transactions (+N bcrypt)
medium Fully Valid high confidence
Status
Fully Valid
Complete amplification chain proven end-to-end through the handler, bolt backend, and storm v3.2.1 dependency source: unbounded req.Which -> N fsync'd write transactions (+N bcrypt). Directly triggerable by an authenticated user against their own account.
Repository / Component
Plain-English Description
A single request to update a user account can name the same field thousands of times, and the server performs one separate disk-syncing database write for each entry (and a full password hash for each 'Password' entry). One authenticated user can turn one request into massive disk and CPU work.
Description of the Underlying Issue
PUT /api/users/{id} (userPutHandler, http/users.go:180-269) accepts an attacker-controlled `req.Which` list naming which fields to update, with no length cap or de-duplication. The only gates are len==0 and (len==1 && 'all') at :219; the len!=0 rejection at :146 belongs to userPostHandler (POST), not PUT. The handler loops per field (:241-261), calls users.ValidateAndHashPwd (bcrypt) per 'Password' entry (:250), then calls d.store.Users.Update(req.Data, req.Which...) (:263). In storage/bolt/users.go:58-75, usersBackend.Update loops the fields calling st.db.UpdateField (:69) once per field. In storm v3.2.1 (store.go:282-338), UpdateField -> node.update -> n.readWriteTx (:324); node.go:101-115 readWriteTx falls through to n.s.Bolt.Update, opening a fresh write transaction, and store.go:161 node.save re-serializes and Puts. Because cmd/utils.go:176 opens storm with nil *bolt.Options, NoSync=false (fsync per commit) and there is no batch mode. There is no request-body-size limit anywhere (no http.MaxBytesReader in http/; cmd/root.go:246-248 sets only ReadHeaderTimeout, no ReadTimeout), so req.Which length is effectively unbounded.
Potential Attack
An authenticated user (self-service on their own account via withSelfOrAdmin, or an admin) sends a single PUT /api/users/{id} with a valid data object and which=[Locale,Locale,... x10000] (or [Password,Password,...]). The server executes ~10000 independent BoltDB write transactions, each fsync'd to disk, and for the Password field ~10000 full bcrypt hashes — all for one request. Multiple concurrent such requests multiply the disk-I/O and CPU pressure.
Outcomes of Potential Attack
Resource-exhaustion / availability degradation: sustained fsync'd write-transaction storms cause disk I/O saturation and BoltDB write contention (a single-writer database), and the Password path adds heavy CPU load from repeated bcrypt hashing. Concurrent requests can push the server toward I/O and CPU exhaustion, degrading or denying service for all users, from an ordinary authenticated account acting on its own user record.
Affected Scope
PUT /api/users/{id} (userPutHandler, http/users.go:180-269); authenticated self-service on own account (withSelfOrAdmin) or admin.
Suggested Fix (plain english)
Limit and de-duplicate the list of fields a single update request may change, cap request body size, and update all fields in one database transaction instead of one per field.
Suggested Fix (detailed)
In userPutHandler (http/users.go:180-269): validate req.Which against a whitelist of known User fields, reject unknown or duplicate entries, and bound its length to a small constant. Hash the password at most once per request regardless of how many times 'Password' appears. Apply http.MaxBytesReader to request bodies (and set a server ReadTimeout in cmd/root.go:246-248). In storage/bolt/users.go usersBackend.Update, perform the multi-field update inside a single storm/Bolt transaction (e.g. open one node.Begin/readWriteTx and apply all UpdateField calls within it) rather than one write transaction per field. Verify that a request naming a field N times results in a bounded, constant number of write transactions and at most one bcrypt hash. Cap and de-duplicate req.Which (reject unknown/duplicate fields and bound its length), apply http.MaxBytesReader to request bodies, perform the multi-field update inside a single storm/Bolt transaction rather than one transaction per field, and hash the password at most once per request.
Validation
Complete amplification chain proven end-to-end through the handler, bolt backend, and storm v3.2.1 dependency source: unbounded req.Which -> N fsync'd write transactions (+N bcrypt). Directly triggerable by an authenticated user against their own account.
Full Evidence
http/users.go:180-269 userPutHandler: no length cap on req.Which; only gates are len==0 / (len==1 && 'all') at :219; the len!=0 rejection at :146 is in userPostHandler (POST), not PUT
http/users.go:241-261 per-field loop; :250 users.ValidateAndHashPwd (bcrypt) called per 'Password' entry; :263 d.store.Users.Update(req.Data, req.Which...)
storage/bolt/users.go:58-75 usersBackend.Update loops fields calling st.db.UpdateField (:69) once per field
storm v3.2.1 store.go:282-338 UpdateField->node.update->n.readWriteTx(:324); node.go:101-115 readWriteTx falls through to n.s.Bolt.Update (fresh write tx); store.go:161 node.save re-serializes and Puts
cmd/utils.go:176 storm.Open(path, storm.BoltOptions(databasePermissions, nil)) -> nil *bolt.Options => NoSync=false (fsync per commit), no batch mode
cmd/root.go:246-248 http.Server sets only ReadHeaderTimeout (no ReadTimeout/body cap); no MaxBytesReader anywhere in http/ (grep)
Proven fact: req.Which is attacker-controlled and unbounded; one PUT with N field entries drives N iterations of usersBackend.Update -> N st.db.UpdateField calls.
Proven fact: Each UpdateField on the top-level DB node (n.tx==nil, batchMode==false) opens its own n.s.Bolt.Update write transaction, which fsyncs on commit because bolt.Options is nil (NoSync default false).
Proven fact: Each req.Which entry normalizing to 'Password' additionally triggers a full bcrypt hash at users.go:250.
Proven fact: No request body-size limit exists (no MaxBytesReader; server sets no ReadTimeout), so req.Which length is effectively unbounded.
Unvalidated fact: For a non-admin, the 'Password' bcrypt path additionally requires !LockPassword (users.go:246); non-password fields must be valid User struct field names and, for non-admin, not in NonModifiableFieldsForNonAdmin (users.go:256) - but a modifiable field such as 'Locale' repeated N times still yields N fsync'd write transactions.
Unvalidated fact: Exact per-request ceiling depends on the (unbounded here) maximum accepted request body size.
http/users.go:180-269 userPutHandler: no length cap on req.Which; only gates are len==0 / (len==1 && 'all') at :219; the len!=0 rejection at :146 is in userPostHandler (POST), not PUT.
http/users.go:241-261 per-field loop; :250 users.ValidateAndHashPwd (bcrypt) called per 'Password' entry; :263 d.store.Users.Update(req.Data, req.Which...).
storage/bolt/users.go:58-75 usersBackend.Update loops fields calling st.db.UpdateField (:69) once per field.
storm v3.2.1 store.go:282-338 UpdateField->node.update->n.readWriteTx(:324); node.go:101-115 readWriteTx falls through to n.s.Bolt.Update (fresh write tx); store.go:161 node.save re-serializes and Puts.
cmd/utils.go:176 storm.Open(path, storm.BoltOptions(databasePermissions, nil)) -> nil *bolt.Options => NoSync=false (fsync per commit), no batch mode.
cmd/root.go:246-248 http.Server sets only ReadHeaderTimeout (no ReadTimeout/body cap); no MaxBytesReader anywhere in http/.
Unvalidated fact: For a non-admin, the 'Password' bcrypt path additionally requires !LockPassword (users.go:246); non-password fields must be valid User struct field names and, for non-admin, not in NonModifiableFieldsForNonAdmin (users.go:256) — but a modifiable field such as 'Locale' repeated N times still yields N fsync'd write transactions.
http/users.go:180-269 userPutHandler: no upper bound on req.Which (only len==0/==1 gate at :219; the len!=0 rejection at :146 is in userPostHandler, not PUT)
http/users.go:241-261 per-field loop; :250 ValidateAndHashPwd (bcrypt) called per 'Password' entry; :263 Users.Update(req.Data, req.Which...) with unbounded req.Which
storage/bolt/users.go:58-75 usersBackend.Update loops over fields calling st.db.UpdateField once per field
storm/v3@v3.2.1 store.go:282-305 UpdateField -> node.update; node.go:101-115 readWriteTx falls through to n.s.Bolt.Update (a fresh write transaction per call); store.go:161+ node.save re-serializes and Puts unconditionally
cmd/utils.go:176 storm.Open(path, storm.BoltOptions(databasePermissions, nil)) -> nil bolt.Options => NoSync=false => fsync per commit
cmd/root.go:246-248 http.Server sets only ReadHeaderTimeout (no ReadTimeout, no MaxHeaderBytes, no body cap); grep of http/ finds no MaxBytesReader
Coverage gap: availability-and-resource-abuse lens has run for 8 other components but never for users-management (.dokima/state/runs/); no dismissed/candidate record bounds request body or which length