Rocket.Chat's CAS login handler forwards the client-supplied options.cas.credentialToken value straight into a MongoDB findOne({_id: ...}) query without any runtime type check. TypeScript's string parameter annotation is erased at runtime, so an unauthenticated attacker can substitute a MongoDB query operator ({"$gt": ""}, {"$ne": null}, etc.) for what the server expects to be an opaque ticket string. The injected operator matches the first unexpired document in the credential_tokens collection, bypassing the CAS ticket check entirely.
When any legitimate CAS or SAML SSO login is in flight, the attacker's next DDP login call matches the same credential-token row via the NoSQL operator and is issued a full Meteor auth token (userId + token) bound to the victim. The token is immediately usable against the complete REST and DDP surface as that user. If the victim is an administrator, this escalates to full instance compromise via Apps-Engine app install.
Because the credential_tokens collection is populated by both the CAS middleware and the SAML service, and because the vulnerable CAS handler is registered unconditionally at startup regardless of whether CAS is actually enabled, any Rocket.Chat instance with either SSO provider configured is exploitable.
Pre-requisites
- No account needed. Attacker is fully unauthenticated; they only need network reach to the instance's
/websocket (Meteor DDP) or /api/v1/login endpoint.
- The target instance must have CAS or SAML configured and actively in use. Both providers write into the same
credential_tokens collection that the CAS handler reads, so a SAML-only deployment is exploitable through the CAS handler's NoSQL primitive too.
- A legitimate SSO login must occur (or have occurred) within the 60-second credential-token window (
CredentialTokens.ts:17, hardcoded validForMilliseconds = 60000). On an SSO-backed instance any user logging in creates that window. An attacker polling at sub-second intervals wins the race deterministically when a row appears.
Root Cause
1. No type validation on credentialToken in the CAS handler:
apps/meteor/server/lib/cas/loginHandler.ts#L12-L21:
export const loginHandlerCAS = async (options: any): Promise<...> => {
if (!options.cas) {
return undefined;
}
const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken);
if (credentials === undefined || credentials === null) {
throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found');
}
...
options is typed any and options.cas.credentialToken is passed straight to the model at line 18. Compare with the SAML sibling handler at app/meteor-accounts-saml/server/loginHandler.ts#L14-L17, which has the exact guard the CAS handler lacks:
Accounts.registerLoginHandler('saml', async (loginRequest) => {
if (!loginRequest.saml || !loginRequest.credentialToken || typeof loginRequest.credentialToken !== 'string') {
return undefined;
}
...
The guard on the SAML handler prevents the equivalent operator-object injection on that path; the CAS handler has no corresponding check.
2. Raw query composition in the model:
packages/models/src/models/CredentialTokens.ts#L28-L35:
findOneNotExpiredById(_id: string): Promise<ICredentialToken | null> {
const query = {
_id,
expireAt: { $gt: new Date() },
};
return this.findOne(query);
}
The _id: string annotation is compile-time only. Whatever the handler passes - string or object - is placed into the query document as-is. {_id: {$gt: ""}} is a valid Mongo query that matches every document with any non-empty _id (i.e. every row in the collection), combined with the existing expireAt: {$gt: new Date()} filter, it matches any currently-valid credential token.
3. CAS handler registered unconditionally at startup:
apps/meteor/server/configuration/cas.ts#L30-L36:
Accounts.registerLoginHandler('cas', (options) => {
const promise = loginHandlerCAS(options);
return promise as unknown as Awaited<typeof promise>;
});
configureCAS runs at server startup with no CAS_enabled gate. The DDP login method accepts {cas: {...}} payloads on every Rocket.Chat instance, and the vulnerable handler is reached regardless of whether CAS has ever been configured in the admin UI. The only thing standing between the attacker and a hit is a non-empty credential_tokens collection.
4. Cross-provider attack surface via SAML writes into the same collection:
apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts#L99-L101:
public static async storeCredential(credentialToken: string, loginResult: { profile: Record<string, any> }): Promise<void> {
await CredentialTokens.create(credentialToken, loginResult);
}
SAML and CAS share the same credential_tokens collection. The vulnerable CAS handler will happily return SAML-populated rows to an attacker injecting through the cas login path. This widens the affected population from "CAS-enabled instances" to "CAS-enabled or SAML-enabled instances" - the latter being substantially larger in enterprise deployments.
5. Short but deterministic race window:
packages/models/src/models/CredentialTokens.ts#L16-L26:
async create(_id: string, userInfo: ICredentialToken['userInfo']): Promise<ICredentialToken> {
const validForMilliseconds = 60000; // Valid for 60 seconds
const token = {
_id,
userInfo,
expireAt: new Date(Date.now() + validForMilliseconds),
};
await this.insertOne(token);
return token;
}
The CAS handler does not delete the row on successful read - it only filters on expireAt. So for the full 60 seconds after an SSO callback completes, the row remains in the collection and is injectable on any $gt:"" poll. Mongo's TTL index on expireAt sweeps after expiry but the 60-second window is deterministic for the attacker.
Impact
- Full account takeover of any user who performs CAS or SAML SSO login while the attacker is polling. Stolen
authToken is a first-class Meteor session token: usable via X-Auth-Token / X-User-Id headers against /api/v1/*, and via DDP login with {resume: token} for full WebSocket access.
- Admin escalation if the victim is an admin: attacker installs an arbitrary Apps-Engine app for server-side code execution, modifies settings, exfiltrates every user's data.
- Cross-provider blast radius: SAML-only instances (which do not use CAS at all) are still exploitable because the CAS handler cross-reads the collection SAML writes into. Operators who disabled CAS long ago remain exposed.
- No auth, no account, no user interaction with the attacker. The attacker never touches the IdP; they only hit Rocket.Chat's own
/websocket.
- Silent to the victim: the victim's own CAS login completes normally (the handler does not delete the credential-token row on read), so the attack leaves no user-visible trace. Server-side DDP access logs will record an extra
login method call per hijack whose params object contains the operator form {cas:{credentialToken:{"$gt":""}}} - this is distinguishable from legitimate client traffic (which sends a string credentialToken) and a log reviewer can detect the pattern with a grep for "$gt" or equivalent in the method: "login" payloads, but by default RC does not log DDP method bodies, so nothing on disk captures the operator object without extra instrumentation.
Rocket.Chat's CAS login handler forwards the client-supplied
options.cas.credentialTokenvalue straight into a MongoDBfindOne({_id: ...})query without any runtime type check. TypeScript'sstringparameter annotation is erased at runtime, so an unauthenticated attacker can substitute a MongoDB query operator ({"$gt": ""},{"$ne": null}, etc.) for what the server expects to be an opaque ticket string. The injected operator matches the first unexpired document in thecredential_tokenscollection, bypassing the CAS ticket check entirely.When any legitimate CAS or SAML SSO login is in flight, the attacker's next DDP
logincall matches the same credential-token row via the NoSQL operator and is issued a full Meteor auth token (userId+token) bound to the victim. The token is immediately usable against the complete REST and DDP surface as that user. If the victim is an administrator, this escalates to full instance compromise via Apps-Engine app install.Because the
credential_tokenscollection is populated by both the CAS middleware and the SAML service, and because the vulnerable CAS handler is registered unconditionally at startup regardless of whether CAS is actually enabled, any Rocket.Chat instance with either SSO provider configured is exploitable.Pre-requisites
/websocket(Meteor DDP) or/api/v1/loginendpoint.credential_tokenscollection that the CAS handler reads, so a SAML-only deployment is exploitable through the CAS handler's NoSQL primitive too.CredentialTokens.ts:17, hardcodedvalidForMilliseconds = 60000). On an SSO-backed instance any user logging in creates that window. An attacker polling at sub-second intervals wins the race deterministically when a row appears.Root Cause
1. No type validation on
credentialTokenin the CAS handler:apps/meteor/server/lib/cas/loginHandler.ts#L12-L21:optionsis typedanyandoptions.cas.credentialTokenis passed straight to the model at line 18. Compare with the SAML sibling handler atapp/meteor-accounts-saml/server/loginHandler.ts#L14-L17, which has the exact guard the CAS handler lacks:The guard on the SAML handler prevents the equivalent operator-object injection on that path; the CAS handler has no corresponding check.
2. Raw query composition in the model:
packages/models/src/models/CredentialTokens.ts#L28-L35:The
_id: stringannotation is compile-time only. Whatever the handler passes - string or object - is placed into the query document as-is.{_id: {$gt: ""}}is a valid Mongo query that matches every document with any non-empty_id(i.e. every row in the collection), combined with the existingexpireAt: {$gt: new Date()}filter, it matches any currently-valid credential token.3. CAS handler registered unconditionally at startup:
apps/meteor/server/configuration/cas.ts#L30-L36:configureCASruns at server startup with noCAS_enabledgate. The DDPloginmethod accepts{cas: {...}}payloads on every Rocket.Chat instance, and the vulnerable handler is reached regardless of whether CAS has ever been configured in the admin UI. The only thing standing between the attacker and a hit is a non-emptycredential_tokenscollection.4. Cross-provider attack surface via SAML writes into the same collection:
apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts#L99-L101:SAML and CAS share the same
credential_tokenscollection. The vulnerable CAS handler will happily return SAML-populated rows to an attacker injecting through thecaslogin path. This widens the affected population from "CAS-enabled instances" to "CAS-enabled or SAML-enabled instances" - the latter being substantially larger in enterprise deployments.5. Short but deterministic race window:
packages/models/src/models/CredentialTokens.ts#L16-L26:The CAS handler does not delete the row on successful read - it only filters on
expireAt. So for the full 60 seconds after an SSO callback completes, the row remains in the collection and is injectable on any$gt:""poll. Mongo's TTL index onexpireAtsweeps after expiry but the 60-second window is deterministic for the attacker.Impact
authTokenis a first-class Meteor session token: usable viaX-Auth-Token/X-User-Idheaders against/api/v1/*, and via DDPloginwith{resume: token}for full WebSocket access./websocket.loginmethod call per hijack whose params object contains the operator form{cas:{credentialToken:{"$gt":""}}}- this is distinguishable from legitimate client traffic (which sends a stringcredentialToken) and a log reviewer can detect the pattern with a grep for"$gt"or equivalent in themethod: "login"payloads, but by default RC does not log DDP method bodies, so nothing on disk captures the operator object without extra instrumentation.