Summary
DeviceCollectionService.SetRackSlotPosition (and its DeviceSetService facade) writes a slot position without ever reading the rack's dimensions, so it accepts positions outside the grid. It also takes no rack row lock and does not verify membership.
Found while reviewing #855, which fixed the equivalent gaps in the batch path (AssignDevicesToRack). This is the pre-existing single-slot path and is not touched by that PR.
Example
Rack R1 is 2×2, so the only valid cells are (0,0), (0,1), (1,0), (1,1). Miner M is a member of R1.
| Call |
Result |
AssignDevicesToRack{target_rack_id: R1, slot_assignments: [{M, row: 5, col: 5}]} |
rejected — slot row 5 is out of bounds (rack has 2 rows) |
SetRackSlotPosition{collection_id: R1, device_identifier: M, position: {row: 5, col: 5}} |
200 OK, row persisted |
M now has a rack_slot row at a cell the 2×2 grid can never render. It reads as placed in the data but is invisible in the grid UI, so an operator cannot drag it off — recovering needs a ClearRackSlotPosition API call.
No race is required for this; the bounds check is simply absent. buf.validate on RackSlotPosition only constrains row >= 0 / column >= 0 (proto/collection/v1/collection.proto), with no upper bound.
Two related gaps in the same method
No rack lock. A bounds check alone would still be racy. Resize goes through resolveAndApplyRackPlacement, which takes LockRackPlacementForWrite; the slot RPC takes none, so:
- B places
M at (11,11) in a 12×12 rack — reads dims, 11 < 12, passes
- A resizes
R1 to 2×2 — commits
- B writes (11,11) — same orphaned slot
Silent success for a non-member. The store query is INSERT INTO rack_slot ... SELECT ... FROM device_set_membership WHERE .... If the device is not a member, zero rows insert, no error is raised, and the RPC returns 200 for a placement that never happened. This is the same bug Codex flagged in applyRackSlotDelta on #855, which was fixed there but not here.
Proposed fix
Mirror what AssignDevicesToRack already does, inside the existing transaction in Service.SetRackSlotPosition (server/internal/domain/collection/service.go). Both the collection and deviceset handlers delegate to this one service method, so there is a single place to change:
LockRackPlacementForWrite(ctx, req.CollectionId, orgID) before the reads, so the checks hold for the transaction (this also gives the canonical rack-first lock order).
GetRackInfo(...); treat nil as a broken invariant and fail — a RACK always has a device_set_rack row.
- Reject
row >= rackInfo.Rows or column >= rackInfo.Columns with InvalidArgument, matching the batch path's wording.
- Confirm the device is actually a member before reporting success, rather than relying on the query's silent no-op.
Roughly 15 lines plus tests: out-of-bounds rejected, non-member rejected, in-bounds still succeeds.
Worth considering alongside: ClearRackSlotPosition has the same no-lock, no-membership-check shape, though with no bounds concern since it only deletes.
Severity
Low. Triggering it needs PermRackManage plus a direct API or CLI call — the web UI only offers rendered cells, so it will not happen by accident. Nothing crashes; the symptom is a miner stuck in an unrenderable slot.
Notes
An occupied-cell collision on this RPC used to surface as a 500. #855 maps uk_rack_slot_position to InvalidArgument in the store, which covers this RPC too, so that part is already handled once #855 lands.
Summary
DeviceCollectionService.SetRackSlotPosition(and itsDeviceSetServicefacade) writes a slot position without ever reading the rack's dimensions, so it accepts positions outside the grid. It also takes no rack row lock and does not verify membership.Found while reviewing #855, which fixed the equivalent gaps in the batch path (
AssignDevicesToRack). This is the pre-existing single-slot path and is not touched by that PR.Example
Rack
R1is 2×2, so the only valid cells are (0,0), (0,1), (1,0), (1,1). MinerMis a member ofR1.AssignDevicesToRack{target_rack_id: R1, slot_assignments: [{M, row: 5, col: 5}]}slot row 5 is out of bounds (rack has 2 rows)SetRackSlotPosition{collection_id: R1, device_identifier: M, position: {row: 5, col: 5}}Mnow has arack_slotrow at a cell the 2×2 grid can never render. It reads as placed in the data but is invisible in the grid UI, so an operator cannot drag it off — recovering needs aClearRackSlotPositionAPI call.No race is required for this; the bounds check is simply absent.
buf.validateonRackSlotPositiononly constrainsrow >= 0/column >= 0(proto/collection/v1/collection.proto), with no upper bound.Two related gaps in the same method
No rack lock. A bounds check alone would still be racy. Resize goes through
resolveAndApplyRackPlacement, which takesLockRackPlacementForWrite; the slot RPC takes none, so:Mat (11,11) in a 12×12 rack — reads dims, 11 < 12, passesR1to 2×2 — commitsSilent success for a non-member. The store query is
INSERT INTO rack_slot ... SELECT ... FROM device_set_membership WHERE .... If the device is not a member, zero rows insert, no error is raised, and the RPC returns 200 for a placement that never happened. This is the same bug Codex flagged inapplyRackSlotDeltaon #855, which was fixed there but not here.Proposed fix
Mirror what
AssignDevicesToRackalready does, inside the existing transaction inService.SetRackSlotPosition(server/internal/domain/collection/service.go). Both thecollectionanddevicesethandlers delegate to this one service method, so there is a single place to change:LockRackPlacementForWrite(ctx, req.CollectionId, orgID)before the reads, so the checks hold for the transaction (this also gives the canonical rack-first lock order).GetRackInfo(...); treatnilas a broken invariant and fail — a RACK always has adevice_set_rackrow.row >= rackInfo.Rowsorcolumn >= rackInfo.Columnswith InvalidArgument, matching the batch path's wording.Roughly 15 lines plus tests: out-of-bounds rejected, non-member rejected, in-bounds still succeeds.
Worth considering alongside:
ClearRackSlotPositionhas the same no-lock, no-membership-check shape, though with no bounds concern since it only deletes.Severity
Low. Triggering it needs
PermRackManageplus a direct API or CLI call — the web UI only offers rendered cells, so it will not happen by accident. Nothing crashes; the symptom is a miner stuck in an unrenderable slot.Notes
An occupied-cell collision on this RPC used to surface as a 500. #855 maps
uk_rack_slot_positionto InvalidArgument in the store, which covers this RPC too, so that part is already handled once #855 lands.