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.
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 tofindOne({...})against theoauth_appsandoauth_access_tokenscollections, so an attacker substitutes{"$ne": null}forclient_id,client_secret, andrefresh_tokenand 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/$regexoperators the attacker walks the entireoauth_access_tokenscollection, 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
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-servervalidator silently coerces objects to strings:/oauth/tokenis served by@node-oauth/oauth2-serverfrom within an Express app configured withexpress.json()atapps/meteor/server/oauth2-server/oauth.ts#L24-L34. A POST withContent-Type: application/jsonand a body containing nested objects (e.g.{"client_id": {"$ne": null}}) is parsed as-is -req.body.client_idbecomes a JavaScript object rather than a string.A middleware at
oauth.ts#L71-L80rewrites the request'sContent-Typeheader toapplication/x-www-form-urlencodedbefore handing the request to the OAuth2 library, but it does not re-serialise or validate the body - the nested objects remain intact:Inside
@node-oauth/oauth2-server, the refresh-token grant handler validates parameters viais.vschar(value), which internally callsvalidator.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/authorizeatoauth.ts#L95-L98does includeif (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:TypeScript's
stringannotations 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 actualclientId/clientSecret- client authentication is bypassed in one request.packages/models/src/models/OAuthAccessTokens.ts#L28-L33follows the same pattern:The
if (!refreshToken) return nullguard 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 infindOne. The resulting queryfindOne({refreshToken: {$ne: null}})returns the first document whoserefreshTokenfield 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'srefresh-token-grant-type.js, after the token is fetched the library verifiestoken.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 sameclientId- the check passes on the first request.For deployments with multiple OAuth apps, the attacker narrows the
client_idmatch 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 includedexploit.pyautomates 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.