agentggagentgg
Back to all findings
MEDIUMconfirmedover-fetched-responseover-fetched-responseb951bc3f1f09

Login response token embeds full User row (password hash and totpSecret)

The login handler signs the entire authenticated User row — including the password hash and TOTP secret — into the JWT it returns, so any holder of the token can decode those fields.

Fileroutes/login.ts
Lines3347
Confidence
90%
File statusvalidated
Details

Line 33 executes SELECT * FROM Users WHERE …, so authenticatedUser includes every column on the Users table: password (MD5 hash, per models/user.ts:74-79) and totpSecret (per models/user.ts:113-116). The result is wrapped via utils.queryResultToJson (just { status, data }) and passed unchanged to afterLoginsecurity.authorize(user) (routes/login.ts:22).

export const authorize = (user = {}) => jwt.sign(user, privateKey, { expiresIn: '6h', algorithm: 'RS256' })

lib/insecurity.ts:56jwt.sign only signs, it does not encrypt. The full data object (password hash, totpSecret, role, etc.) is base64-encoded inside the JWT body and returned in the response as { authentication: { token, … } }. Anyone with the token (the user, anyone who intercepts it, anyone who acquires it via XSS or a related leak) can atob the middle segment and read data.password and data.totpSecret.

There is no Sequelize defaultScope exclusion, no DTO/allowlist, and no toJSON override stripping these fields before they reach security.authorize.

Proof of concept

Login normally:

POST /rest/user/login {"email":"jim@…","password":"…"}
→ {"authentication":{"token":"eyJhbGciOi…","bid":…,"umail":"jim@…"}}

Decode segment 2 of the JWT: echo eyJ…payload… | base64 -d reveals "data":{"id":…, "email":"…", "password":"<md5_hash>", "totpSecret":"…", "role":"…", …}.

Impact

Every successful login leaks the user's stored password hash (suitable for offline cracking — MD5 is trivial) and their TOTP shared secret, defeating 2FA for that account. A token intercepted in transit or via a cross-site leak yields both factors in one shot.

Validation
confirmed

The code on line 33 uses SELECT * to retrieve the full Users row, wraps it via utils.queryResultToJson, and passes the resulting user object directly to security.authorize(user) which calls jwt.sign — JWTs are signed not encrypted, so the payload (including password MD5 hash and totpSecret) is base64-encoded and readable by any token holder. The token is then returned to the client in the JSON response (res.json({ authentication: { token, ... } })). No field filtering, DTO, or scope exclusion is applied before signing. The exploit chain is straightforward and reachable from an unauthenticated login.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Base score: 6.5 · MEDIUM

The vulnerable sink is the public /rest/user/login HTTP endpoint (AV:N), reachable with a normal login request and no special preconditions (AC:L). To obtain a token containing the sensitive fields the attacker must present valid credentials (PR:L) — the SELECT * FROM Users on line 33 only returns a row after email+password match — but no victim action is needed (UI:N) and the disclosure stays within the application's own trust boundary (S:U). Confidentiality is High because the JWT body returned in { authentication: { token } } is merely base64-encoded (jwt.sign does not encrypt, per lib/insecurity.ts:56) and fully exposes data.password (MD5 hash, crackable offline) and data.totpSecret (defeats 2FA), plus role and other columns; any subsequent token leak (XSS, logs, referer, interception) hands an attacker both auth factors. There is no write path or service-disruption path through this sink, so Integrity and Availability are None.

References