Skip to content

Pre-Auth NoSQL Injection in OAuth2 Token Endpoint leading to Arbitrary User ATO

Critical
julio-rocketchat published GHSA-8p25-fm45-pjrw May 14, 2026

Package

Rocket.Chat

Affected versions

<8.5.0, <8.4.1, <8.3.3, <8.2.3, <8.1.4, <8.0.5, <7.13.7, <7.10.11

Patched versions

8.5.0, 8.4.1, 8.3.3, 8.2.3, 8.1.4, 8.0.5, 7.13.7, 7.10.11

Description

Summary

An unauthenticated network attacker obtains a valid Rocket.Chat OAuth access token for an arbitrary user by sending a single HTTP POST with MongoDB query operators to /oauth/token. The Rocket.Chat OAuth2 server does not validate that grant parameters are strings before forwarding them to findOne({...}) against the oauth_apps and oauth_access_tokens collections, so an attacker substitutes {"$ne": null} for client_id, client_secret, and refresh_token and receives a freshly minted {access_token, refresh_token} pair bound to whichever user's refresh token Mongo returned first.

The resulting access token is a first-class bearer credential against the full /api/v1/* surface as that user. By iterating with $nin / $regex operators the attacker walks the entire oauth_access_tokens collection, collecting one fresh access token per user per request. If any matched token belongs to an admin, the stolen bearer gives full admin API access (including Apps-Engine app installation, i.e. server-side code execution). No account, credentials, userId, or prior interaction with the instance are required.

Pre-requisites

  • The target instance must have at least one active OAuth app registered (Admin → Third-party Login). This is standard on any deployment that uses OAuth integrations (Zapier, n8n, IFTTT, Make, mobile companion apps, "Sign in with Rocket.Chat" on internal SaaS, Jenkins/CI automation, custom dashboards).
  • At least one refresh token must exist in oauth_access_tokens. This is populated whenever any user completes the OAuth authorisation flow for any registered app. Refresh tokens persist in the collection (the server rotates but does not clear them on normal use), so a single historical authorisation is sufficient for the vulnerability to remain live indefinitely.

Root Cause

1. oauth2-server validator silently coerces objects to strings:

/oauth/token is served by @node-oauth/oauth2-server from within an Express app configured with express.json() at apps/meteor/server/oauth2-server/oauth.ts#L24-L34. A POST with Content-Type: application/json and a body containing nested objects (e.g. {"client_id": {"$ne": null}}) is parsed as-is - req.body.client_id becomes a JavaScript object rather than a string.

A middleware at oauth.ts#L71-L80 rewrites the request's Content-Type header to application/x-www-form-urlencoded before handing the request to the OAuth2 library, but it does not re-serialise or validate the body - the nested objects remain intact:

const transformRequestsNotUsingFormUrlencodedType = function (req, _res, next) {
    if (!req.is('application/x-www-form-urlencoded') && req.method === 'POST') {
        req.headers['content-type'] = 'application/x-www-form-urlencoded';
        req.body = Object.assign({}, req.body, req.query);
    }
    return next();
};

Inside @node-oauth/oauth2-server, the refresh-token grant handler validates parameters via is.vschar(value), which internally calls validator.matches(value, /^[\u0020-\u007E]+$/). validator.matches() coerces non-string inputs to their string representation - {"$ne": null} becomes "[object Object]", which consists entirely of printable ASCII characters and therefore passes the vschar regex. The library then forwards the original object (not the coerced string) to the model layer.

Notably, /oauth/authorize at oauth.ts#L95-L98 does include if (typeof req.query.client_id !== 'string') as a guard. The same guard is absent on /oauth/token.

2. Models accept query operators directly into findOne:

packages/models/src/models/OAuthApps.ts#L40-L53:

findOneActiveByClientIdAndClientSecret(
    clientId: string,
    clientSecret: string,
    options?: FindOptions<IOAuthApps>,
): Promise<IOAuthApps | null> {
    return this.findOne(
        {
            active: true,
            clientId,
            clientSecret,
        },
        options,
    );
}

TypeScript's string annotations are compile-time only. The runtime value {"$ne": null} is placed into MongoDB's query document as-is. The resulting query {active: true, clientId: {$ne: null}, clientSecret: {$ne: null}} matches the first active OAuth app regardless of its actual clientId / clientSecret - client authentication is bypassed in one request.

packages/models/src/models/OAuthAccessTokens.ts#L28-L33 follows the same pattern:

async findOneByRefreshToken(refreshToken: string, options?: FindOptions<IOAuthAccessToken>): Promise<IOAuthAccessToken | null> {
    if (!refreshToken) {
        return null;
    }
    return this.findOne({ refreshToken }, options);
}

The if (!refreshToken) return null guard only rejects falsy values (null, undefined, "", 0, false). An operator object such as {"$ne": null} is truthy, so it slips past the guard and lands directly in findOne. The resulting query findOne({refreshToken: {$ne: null}}) returns the first document whose refreshToken field is any non-null value - i.e. the first refresh token in the collection.

3. No cross-check that returned token belongs to returned client on multi-app instances:

In @node-oauth/oauth2-server's refresh-token-grant-type.js, after the token is fetched the library verifies token.client.id === client.id. In the typical deployment with a single OAuth app registered, the first-matched client (from query 1) and the first-matched refresh token (from query 2, which was originally issued to that same client) share the same clientId - the check passes on the first request.

For deployments with multiple OAuth apps, the attacker narrows the client_id match with {"$regex": "^<prefix>"} via binary search on the 64-char nanoid charset ([-0-9A-Z_a-z]) until the regex uniquely matches the client that owns a refresh token. The included exploit.py automates this fallback; empirically it resolves in ~30 probes on a 5-app instance, taking well under a second.

Impact

Unauthenticated arbitrary user ATO on any Rocket.Chat instance with an active OAuth app plus at least one completed authorisation in history.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

CVE ID

CVE-2026-45689

Weaknesses

Improper Neutralization of Special Elements in Data Query Logic

The product generates a query intended to access or manipulate data in a data store such as a database, but it does not neutralize or incorrectly neutralizes special elements that can modify the intended logic of the query. Learn more on MITRE.

Credits