CVE-2025-29513: Stored XSS in NodeBB Admin API token generator
Overview
CVE-2025-29513 is a stored cross-site scripting (XSS) vulnerability in the Admin API token generator functionality of NodeBB version 4.0.4 and below. This flaw allows an attacker with administrative access to inject arbitrary JavaScript through crafted input in the User ID (uid) field when generating new API tokens.
Although the frontend input form enforces numeric validation, an attacker can bypass these restrictions by using tools like cURL or a web proxy to submit values such as -0 or malformed numeric strings (e.g., -0000.00). These inputs are not properly validated server-side and are reflected in the rendered HTML of the admin panel, leading to persistent script injection.
Once a malicious payload is stored, it is rendered every time the admin accesses the API tokens page, enabling a stored XSS attack. This vulnerability can be abused to hijack admin sessions, escalate privileges, or cause application instability.
Steps
-
Sign in as a privileged user, such as an administrator, and navigate to the
/admin/settings/api
endpoint. -
Observe the token creation process:
- To create a token, click the “Create Token” button within the API Access section.
- Enter a legal value such as
"0"
. - Note the assigned user ID.
-
The value
"0"
is particularly interesting because it is assigned as a Master Token in the API token generator. As values are assigned in the User field of the token, they correlate to specific user IDs.- For example, in my local instance, the
admin
user has an ID of1
. Therefore, if I create a token with the User field set to1
, it would be assigned toadmin
.
A notable aspect of this assigned value is that a user can enter
"-0"
using a web proxy or a tool like cURL.- This does not work when entering it from the frontend due to input sanitization rules that prevent such values.
- However, the backend does not enforce these restrictions, allowing us to submit
-0
and see it reflected in the application.
- For example, in my local instance, the
-
Once the value
-0
is processed, an attacker can continue submitting values. Although the backend appears to reject some inputs, they still appear in the application.
-
Now that the application has confirmed HTML injection, the next step is to attempt injecting script tags for an XSS attack:
<script>alert("NodeBB Hacked!")</script>
It is also worth noting that the application accepts other variations of
-0
, such as-00000
or-000000.00000000
.
Further Analysis: Code Review
src/api/utils.js
This file includes a few new updates not shown in the previous code snippets. Notably, an error is thrown if an invalid ID is entered. Another key adjustment is the use of piped characters to validate whether the entered `uid` is a number. In this context, `uid` represents the token, so any non-numeric characters after the initial `uid` should be considered invalid upon entry. However, in theory, this should not prevent the input of `-0` if `isNumber` still recognizes it as a valid number.
src/views/admin/partials/edit-token-modal.tpl
The updates in this file involve modifying the `uid` and `description` parameters within the input tag’s `id` attribute. Since the vulnerability was found in the `uid` parameter, the input type has been set to `number` to reinforce security measures. These changes enhance validation by strengthening both frontend and backend protections.
The Fix
src/api/utils.js
utils.tokens.add = async ({ token, uid, description = '', timestamp = Date.now() }) => {
if (!token || uid === undefined || !srcUtils.isNumber(uid)) {
throw new Error('[[error:invalid-data]]');
}
src/views/admin/partials/edit-token-modal.tpl
<form role="form">
<div class="mb-3">
<label class="form-label" for="uid">[[admin/settings/api:uid]]</label>
<input id="uid" type="number" inputmode="numeric" pattern="\d+" name="uid" class="form-control" placeholder="0" value="{./uid}" />
<p class="form-text">
[[admin/settings/api:uid-help-text]]
</p>
</div>
<div class="mb-3">
<label class="form-label" for="description">[[admin/settings/api:description]]</label>
<input id="description" type="text" name="description" class="form-control" placeholder="Description" value="{./description}" />
</div>
</form>
For more information, the git commit can be found here.