Breaking the Trust Boundary of a Meteor DDP Backend
- Missing authentication
- Path validation bypass
Breaking the Trust Boundary of a Meteor DDP Backend#
Vulnerability Research — Case Study
A production point-of-sale platform exposed its realtime RPC layer directly to the internet. No session, no token, no login — just a WebSocket handshake and a method name. Here's how an unauthenticated client ended up invoking database-mutating server methods, and reading files it was never supposed to see.
Role: Independent security researcher Class: Missing authentication (CWE-306) · Path validation bypass (CWE-22) Severity: Critical · CVSS 9.0 Status: Reported
The target's identity, exact endpoint names, internal paths, and any string that would let this be replayed against the live system have been removed or genericized. What follows describes the vulnerability class and the reasoning, not a working exploit.
01 — Context#
The target was a dealer-facing point-of-sale and order-logistics system built on Meteor.js. Meteor's signature feature is DDP — Distributed Data Protocol — a WebSocket RPC layer that lets the client call server-side "methods" and subscribe to live data without writing a REST API by hand. That convenience is also the risk: DDP methods are just JavaScript functions registered on the server, and nothing about the protocol enforces authentication unless every single method checks for it itself.
This system didn't.
02 — Finding the door#
The application loaded a WebSocket connection to a SockJS endpoint on first paint, before any login. Opening that connection directly — no cookies, no headers, no prior HTTP session — and sending a bare DDP connect frame was enough to get a live, authenticated-by-the-server-but-not-by-identity session:
client → {"msg":"connect","version":"1","support":["1"]}
server → {"msg":"connected","session":"<opaque id>"}At that point the server treats the connection as a fully functional DDP peer. It just doesn't know who it's talking to — and, critically, most of the server's method handlers never ask.
03 — What was reachable#
Calling method frames against the handler names visible in the client bundle (DDP ships its method map to the browser by design — that's how the frontend knows what to call) surfaced a set of server-side functions with no identity check at all. The pattern repeated across the method surface: a handler would read its parameters, run a database operation or an external API call, and return a result — with no equivalent of if (!this.userId) throw new Meteor.Error(...) anywhere in the call path.
State mutation A method intended to validate a one-time code against a pending order could be called directly with an attacker-supplied order identifier, flipping the order's verification state without ever possessing the code.
File disclosure A file-retrieval method accepted an arbitrary filesystem path from the client, gated only by a prefix check — path.startsWith(ALLOWED_PREFIX) — rather than a resolved, normalized path comparison. Any real upload directory that happened to share that prefix defeated the check entirely, turning a "download my own attachment" method into an arbitrary-file-read primitive scoped to the app's storage tree.
Info disclosure The method map and publication (subscription) names shipped to the browser amounted to a readable index of the backend's internal surface: internal service URLs pulled from server settings, and a build identifier tying the running instance to a specific source revision.
Abuse of integration Other methods triggered calls out to internal notification and cart services with client-controlled parameters — a lever for side effects (like unsolicited notifications) rather than data exposure.
04 — The path check, generalized#
The file-read bypass is worth sitting with, because it's a pattern, not a one-off typo. A prefix check on an unresolved path is not the same claim as "this path is inside the allowed directory":
// looks safe:
if (requestedPath.startsWith("/app")) {
return fs.readFileSync(requestedPath);
}
// but "/app-adjacent-upload-dir/../../etc/passwd".startsWith("/app")
// is also true — and so is any sibling directory that merely
// starts with the same characters as the intended root.The fix isn't a smarter string check — it's not using strings at all. Resolve the path first, then compare the resolved, normalized result against an explicit allowlisted root:
const real = path.resolve(requestedPath);
if (!real.startsWith(ALLOWED_ROOT + path.sep)) {
throw new Meteor.Error("403", "Unauthorized");
}
return fs.readFileSync(real);05 — Impact#
| Primitive | What it bought an attacker | Precondition |
|---|---|---|
| Unauthenticated state mutation | Invalidate verification state on orders/registrations in bulk, without knowledge of the real one-time code | Knowledge or enumeration of an order identifier |
| Unauthenticated file read | Retrieve customer-submitted documents and photos stored under the app's upload tree | Knowledge or enumeration of a stored filename |
| Method & publication enumeration | A near-complete map of the backend's internal RPC and data-subscription surface, for free, pre-auth | None |
None of these required credential theft, session hijacking, or social engineering. They required knowing that the application spoke DDP, and reading the method names the client already shipped.
06 — Root cause and fix#
Two independent failures compounded each other:
- No perimeter control on the DDP/SockJS endpoint itself. A system built for internal, dealer-only use was reachable from the open internet with no network-level gate — no IP allowlist, no auth-aware reverse proxy in front of the WebSocket upgrade.
- No per-method authorization. Meteor leaves identity checks to the developer; it doesn't default to "deny unless authenticated." Every method that skipped the check was, by default, public.
The remediation mirrors the root cause: push identity checks into every method handler as a non-negotiable first line, replace prefix-based path checks with resolve-then-compare logic, and put the transport itself behind network-level authentication so a missed check in application code isn't the only thing standing between the internet and the database.
07 — Takeaway#
Realtime RPC frameworks (DDP, plain WebSockets, gRPC-over-web, GraphQL subscriptions) tend to inherit a REST-era assumption that doesn't hold: that the transport layer enforces something. It usually doesn't. The handshake succeeding is not evidence of anything except that the handshake succeeded. If you're auditing one of these, the first question isn't "what can I call" — the client bundle will tell you that for free — it's "which of these handlers actually checks who's calling."
Reported through the vendor's responsible disclosure process. Published with the vendor's acknowledgment after remediation. — 0xNiemand