Skip to content

RTOP-315: Fix TOCTOU race in first-user bootstrap (/api/create-user) and reconcile stale RBAC docs - #214

Merged
thiagoralves merged 4 commits into
developmentfrom
bugfix/RTOP-315-first-user-race
Oct 7, 2026
Merged

thiagoralves merged 4 commits into
developmentfrom
bugfix/RTOP-315-first-user-race

Conversation

@thiagoralves

@thiagoralves thiagoralves commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes a TOCTOU race in the first-user bootstrap path of /api/create-user that 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:

    • New BootstrapMarker model: one-row sentinel, primary key UNIQUE, created automatically by db.create_all() on upgrade.
    • New module-level _bootstrap_lock = threading.Lock().
    • New _bootstrap_first_admin() helper that holds the lock, re-reads both User and BootstrapMarker inside the lock, inserts the first admin and the sentinel row in the same transaction, and catches IntegrityError as 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.
    • RBAC comment at restapi.py:135-138 rewritten 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_admin fires 5 concurrent POST /api/create-user on an empty DB released by a threading.Barrier, asserts exactly one 201, four 401s, one user row, one sentinel row.
    • test_bootstrap_sentinel_blocks_a_second_bootstrap_after_user_deletion deletes 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, where select_for_update() is a no-op. The Python-level threading.Lock is the primary serialization; BootstrapMarker.id as PRIMARY KEY is 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 the admin/user role model, password hashing cost/algorithm review.

Evidence

  • Unit tests: restapi scope — 115/115 pass (113 pre-existing + 2 new). Race test deterministic across 20 consecutive runs.
  • End-to-end on real hardware: SLM-RP4 (Raspberry Pi 4, ghcr.io/autonomy-logic/openplc-runtime:v4.2.4 image + branch restapi.py docker cp'd into the running container, DB wiped to reopen the bootstrap window). Five concurrent curl POST /api/create-user from a laptop over LAN → exactly one 201 (racer2, id=1, role=admin), four 401s (all within ~3 ms of each other). users table has one row, bootstrap_marker has one row.
  • Manual smoke after restore: backup DB restored (preserves the device's existing admin), openplc-cli upload rtop315_smoke --host slm-rp4.local --target "SLM-RP4" --yes → compile ok, PLC STATUS:RUNNING.

Jira / Confluence

  • Jira: RTOP-315 (Bug, Epic RTOP-240).
  • Cybersecurity Risk Assessment (SEC-02): Confluence page 310771713. Subtask RTOP-318. Pass 1 and pass 2 consolidated; signatures pending (Thiago Alves author, Marcone Silva technical reviewer, Thiago Pio PM) — the three signatures gate the merge, not this PR open.
  • No Requirements Gathering document: this is a bug fix, which the Autonomy process does not require one for.

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

thiagoralves and others added 3 commits October 6, 2026 19:15
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 Gustavohsdp left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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 curl responses with status codes, the users and bootstrap_marker row counts, and the openplc-cli upload output) to the PR or to RTOP-315.

Non-blocking:

  1. Docs. Deleting only the users rows outside the API no longer reopens the bootstrap window; only removing restapi.db does. The CRA records this, but docs/SECURITY.md and docs/TROUBLESHOOTING.md do not mention the bootstrap_marker table. One sentence in each would spare the next operator a surprise.
  2. Jira. Subtask RTOP-318 is still in Backlog while the CRA page is already in review.

Comment thread webserver/restapi.py
return {"id": self.id, "username": self.username, "role": self.role}


class BootstrapMarker(db.Model): # type: ignore[name-defined]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@thiagoralves
thiagoralves merged commit cf0f226 into development Oct 7, 2026
3 checks passed
@thiagoralves
thiagoralves deleted the bugfix/RTOP-315-first-user-race branch October 7, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants