Repository navigation
RTOP-315: Fix TOCTOU race in first-user bootstrap (/api/create-user) and reconcile stale RBAC docs - #214
Conversation
The RBAC comment described the user role as "may edit only its own account and cannot create or delete accounts", omitting that the role may upload programs, start/stop the PLC, debug and read status/logs. docs/SECURITY.md still listed Role-based access control under Future Improvements, though the feature shipped. Both drifted from the published capability table and misled external reviewers into reading other endpoints as RBAC gaps. Rewrite the comment to describe both roles the way the docs site does and remove the stale Future Improvements bullet. No behaviour change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011taWuGDLF7eEJdmYjTLgoy
Concurrent unauthenticated POST /api/create-user calls on an empty database all passed the User.query.first() check and all committed with role=admin, because the check and the commit were not atomic. The set_password step (PBKDF2 at 600,000 iterations) widens the window to ~120 ms on fast hardware and more on an SBC. Serialize the bootstrap branch of create_user with a threading.Lock and re-check inside the lock. Insert a UNIQUE sentinel row (BootstrapMarker) in the same transaction as the first User, and catch IntegrityError on commit as a losing racer. The lock is primary; the sentinel is defense in depth for the lock-bypass case (future multi-process workers). The sentinel table is created by db.create_all on an upgraded device before any new bootstrap call reaches the lock, so legacy devices with users already present stay on the authenticated branch and never touch BootstrapMarker. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011taWuGDLF7eEJdmYjTLgoy
Two tests under tests/pytest/restapi: - test_concurrent_bootstrap_creates_exactly_one_admin fires five concurrent create-user POSTs released by a Barrier on an empty database and asserts exactly one 201, four 401s, one row in the users table with role=admin and one row in bootstrap_marker. Deterministic across 20 consecutive runs. - test_bootstrap_sentinel_blocks_a_second_bootstrap_after_user_deletion exercises the belt-and-suspenders path: delete every user outside the API, confirm the sentinel row survives, and confirm a second unauthenticated create-user is refused with 401. The threading test disposes the engine pool on teardown so no pooled connection carries a stale schema-inspection cache into the next test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011taWuGDLF7eEJdmYjTLgoy
Gustavohsdp
left a comment
There was a problem hiding this comment.
Review summary. The fix itself looks correct: the lock plus the in-lock re-check plus the UNIQUE sentinel in the same transaction close the race, db.create_all() at startup gives upgraded devices the new table before any bootstrap call, and no API path can empty users (an admin cannot delete itself, the missing-admin repair treats zero users as a no-op). The new RBAC comment matches the published capability table. No merge conflict with #215.
Before this merges into development:
- CRA signatures. The three signatures on the Cybersecurity Risk Assessment (Confluence page 310771713) are still pending. The page content already describes what was built; only the approval gate is open.
- Test evidence. The end-to-end run on the SLM-RP4 and the manual smoke are described but no captured output is attached. Please add the raw material (the five
curlresponses with status codes, theusersandbootstrap_markerrow counts, and theopenplc-cli uploadoutput) to the PR or to RTOP-315.
Non-blocking:
- Docs. Deleting only the
usersrows outside the API no longer reopens the bootstrap window; only removingrestapi.dbdoes. The CRA records this, butdocs/SECURITY.mdanddocs/TROUBLESHOOTING.mddo not mention thebootstrap_markertable. One sentence in each would spare the next operator a surprise. - Jira. Subtask RTOP-318 is still in Backlog while the CRA page is already in review.
| return {"id": self.id, "username": self.username, "role": self.role} | ||
|
|
||
|
|
||
| class BootstrapMarker(db.Model): # type: ignore[name-defined] |
There was a problem hiding this comment.
Behaviour change worth documenting: once this row exists, wiping only the users table outside the API no longer reopens the bootstrap window; the operator has to remove restapi.db. Please add a sentence to docs/SECURITY.md (database section) and docs/TROUBLESHOOTING.md (reset procedure) so the recovery path is explicit.
…ery path The TOCTOU fix added a one-row sentinel in restapi.db so that clearing only the users table cannot reopen the first-user bootstrap window. Say so where an operator would look for recovery steps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011taWuGDLF7eEJdmYjTLgoy
Fixes a TOCTOU race in the first-user bootstrap path of
/api/create-userthat let two or more concurrent unauthenticated callers all become administrators side-by-side with the legitimate operator on a factory-fresh or recently-reset device.Reported externally through VINCE CASE#656672 and reproduced on
ghcr.io/autonomy-logic/openplc-runtime:v4.2.4. Same bug class as CVE-2026-104970 in Plane (CWE-362; GHSA-p548-28jp-wr4p; CVSS 3.1 8.1 HIGH,AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H).What changed
webserver/restapi.py:BootstrapMarkermodel: one-row sentinel, primary keyUNIQUE, created automatically bydb.create_all()on upgrade._bootstrap_lock = threading.Lock()._bootstrap_first_admin()helper that holds the lock, re-reads bothUserandBootstrapMarkerinside the lock, inserts the first admin and the sentinel row in the same transaction, and catchesIntegrityErroras a losing racer (returns 401).create_user()restructured: the empty-DB branch delegates to_bootstrap_first_admin(); the authenticated-admin branch is unchanged in behaviour.restapi.py:135-138rewritten to match the published capability table (user role may upload programs, start/stop PLC, debug and read status/logs — not just edit its own account).docs/SECURITY.md: pruned the stale "Role-based access control" line from Future Improvements (feature shipped in v4.1.9).tests/pytest/restapi/test_create_user_race.py(new):test_concurrent_bootstrap_creates_exactly_one_adminfires 5 concurrentPOST /api/create-useron an empty DB released by athreading.Barrier, asserts exactly one 201, four 401s, one user row, one sentinel row.test_bootstrap_sentinel_blocks_a_second_bootstrap_after_user_deletiondeletes every user outside the API, verifies the sentinel survives, and verifies a second unauthenticated POST is refused with 401.Why this shape
Plane's advisory fixed the same bug class in Django/Postgres with
transaction.atomic+select_for_update()+ authoritative re-check inside the lock. Our stack is Flask + SQLAlchemy + SQLite, whereselect_for_update()is a no-op. The Python-levelthreading.Lockis the primary serialization;BootstrapMarker.idasPRIMARY KEYis the defense-in-depth sentinel the lock-bypass case (future multi-process WSGI) needs.Scope
In: the four items above plus the regression tests.
Out: rate limiting on
/api/create-user(defense in depth, future hardening), any change to theadmin/userrole model, password hashing cost/algorithm review.Evidence
ghcr.io/autonomy-logic/openplc-runtime:v4.2.4image + branchrestapi.pydocker cp'd into the running container, DB wiped to reopen the bootstrap window). Five concurrentcurl POST /api/create-userfrom a laptop over LAN → exactly one 201 (racer2, id=1, role=admin), four 401s (all within ~3 ms of each other).userstable has one row,bootstrap_markerhas one row.openplc-cli upload rtop315_smoke --host slm-rp4.local --target "SLM-RP4" --yes→ compile ok, PLCSTATUS:RUNNING.Jira / Confluence
Target release
v4.2.5, before the ~2026-12-28 external disclosure clock on VINCE CASE#656672.
🤖 Generated with Claude Code
https://claude.ai/code/session_011taWuGDLF7eEJdmYjTLgoy