+1 (478) 654-9062

The Security Pass on an Inherited Meteor App: Methods, Publications, and DDP

Most of the Meteor apps we are handed were written by people who are no longer at the company. The code works, revenue depends on it, and nobody currently employed can say with confidence what a hostile client can do over the wire. That question is worth answering early — usually before the upgrade work starts, because the answer occasionally changes the upgrade plan.

This is not a claim that Meteor is insecure. It is a well-built framework and the defaults improved a lot over the years. But Meteor's core idea — a persistent DDP connection where the client calls server methods and subscribes to server publications — concentrates your entire attack surface into two lists of functions. That is a gift for a reviewer. You can read the whole surface in a day. Most stacks cannot say that.

Here is the order we work in.

1. Find out whether the client can still write to the database directly

The first question is whether insecure is gone and what allow/deny rules remain.

meteor list | grep insecure
grep -rn "\.allow(\|\.deny(" --include=*.js --include=*.ts .

If the insecure package is still installed in a production app, stop reading and go remove it; it means any connected client can insert, update, and remove documents in every collection. This is rare but we have found it, usually in an app where a staging settings file drifted into the deploy.

allow/deny rules are the more common finding. They are not a vulnerability by themselves, but they are hard to reason about: the client sends a mutation, the rule votes, and the check has to anticipate every shape of update the client might construct — including $set on fields you forgot existed. A rule that checks doc.ownerId === userId does not stop that owner from setting role: "admin" on their own document unless the rule also constrains which fields may change.

The modern position, and the one Meteor's own docs have pointed at for years, is methods-only writes: remove allow/deny entirely and route every mutation through a named method that validates its arguments and checks authorization in one place you can read. On an inherited app you rarely get to do that in one pass. Instead, inventory the rules, note which collections still accept client-side writes, and treat converting each one to a method as a normal backlog item with a normal priority.

2. Read every method signature and ask what it trusts

Next, list the methods:

grep -rn "Meteor.methods(" --include=*.js --include=*.ts . | wc -l

For each one, three questions:

Are the arguments validated? Meteor gives you check() and Match, and audit-argument-checks turns skipped validation into a loud failure rather than a silent one. Add it in development first; it will fail a handful of methods on day one, and each failure is a method that currently accepts whatever the client sends. The classic exploitable shape is a selector passed straight from the client into a query, where check(selector, Object) is not enough — an attacker sends {} or a $ne and reads or updates rows that were never theirs.

Is this.userId checked, and is it checked correctly? if (!this.userId) throw ... proves the caller is logged in. It does not prove they are allowed to touch this document. Authentication is not authorization, and on apps where the UI only ever offers the legitimate action, the server-side ownership check is the thing most likely to be missing — the client never exercised the bad path, so nobody noticed it existed.

Does the method trust a client-supplied identity? Any argument named userId, orgId, accountId, or role is a flag. The server knows who is calling. If the method takes that as a parameter instead, look hard at whether anything cross-checks it.

3. Treat publications as query results, not as UI

This is the finding we report most often, and it is almost never malicious or careless — it is a UI-shaped habit.

A publication returns documents to the client's minimongo cache. The client renders three fields; the cursor sent forty. Everything in that document is in the browser, visible in the console, whatever the template shows. So:

  • Audit field projections. Meteor.users.find({}) published without a fields projection sends the whole user document. On older apps that can include email addresses, hashed credentials adjacent to services, internal flags, and sometimes notes. Publish an explicit allowlist of fields, never a denylist.
  • Check the null publication. Meteor auto-publishes a slice of the current user's own document. If application code added fields to Meteor.users — billing state, internal scoring, admin notes — those fields may be going to that user's browser without any publication naming them.
  • Scope by this.userId inside the publication. A publication that takes a filter argument from the client and passes it into find() is the pub/sub version of the unvalidated-selector bug. Validate publication arguments exactly as you validate method arguments; they are the same trust boundary.
  • Remember autopublish. Same check as insecure, same severity if it survived into production.

There is a useful side effect here: narrowing publications to the fields the UI actually renders is also the single most reliable pub/sub performance fix. The security pass and the performance pass want the same change, which makes it easy to justify.

4. Put a rate limit on the connection

DDP is a persistent socket, and a client can call a method in a tight loop. Without limits, login attempts, password resets, search methods, and anything that hits an external API are all free to abuse.

DDPRateLimiter ships with Meteor and is not enabled for your application methods by default. A baseline that costs nothing: a global per-connection cap on method calls, a much tighter rule on login and account-creation, and specific rules on any method that sends email, writes to a paid API, or runs an expensive aggregation. Set the limits generously at first and log rejections for a week before tightening — the goal is to stop loops, not to break a legitimate power user.

While you are in there, check what Accounts is configured to do: whether password reset tokens expire sensibly, whether email verification is required where the app assumes it, and whether the browser-policy package (or your own headers, if you moved to Meteor 3's Express-style server) is actually setting a content security policy rather than being installed and unconfigured.

5. Check the dependency surface, both halves of it

A Meteor app has two package managers. npm audit covers the npm half and people run it. The Atmosphere half has no equivalent tooling, so it goes unexamined — and Atmosphere is exactly where the abandoned code lives.

Go through .meteor/packages by hand. For each non-core package: last commit date, open issues, and what it touches. A dormant UI helper is a maintenance question. A dormant package that handles file uploads, authentication, or server routes is a security question, because nobody is shipping fixes for it. That inventory overlaps almost entirely with the one the Meteor 3 upgrade needs, which is a good reason to do both at the same time.

6. The new one: authorization behind a missing await

This is specific to apps that have been through, or are going through, the async conversion, and it is the reason we now run the security pass after that work rather than before.

Under Fibers, this was correct:

const doc = Docs.findOne(id);
if (doc.ownerId !== this.userId) throw new Meteor.Error('forbidden');

Converted carelessly, it becomes:

const doc = Docs.findOneAsync(id);          // no await
if (doc.ownerId !== this.userId) throw ...; // undefined !== userId

...which throws a TypeError, or worse, on a shape where the comparison silently passes. The mirror-image bug is an await-less updateAsync in a permission-revocation path: the promise is created, the function returns, and the revoke may or may not land.

The defense is mechanical, not heroic: no-floating-promises in the linter, and a deliberate re-read of every function containing the words owner, role, admin, permission, or can after the conversion. Grep for those words and read the results with fresh eyes. It is an hour of work and it is the highest-yield hour in the whole review.

What comes out of it

We write this up the same way we write an assessment: a list of findings, each with the file and line, what a client can actually do, and what it costs to fix. Some findings are ten-minute changes. Some — converting every allow rule to a method, narrowing thirty publications — are weeks, and belong in the same plan as the upgrade rather than in front of it.

What we try not to do is turn the list into an argument for a rewrite. None of these findings are framework flaws, and none of them get fixed for free by moving to a different stack; over-publishing and unvalidated input are just as available in Next.js. They are the ordinary accumulation of an application that outlived the team that wrote it. Read the surface, write down what you find, and fix it in priority order.