DEV Community

Taran Douley
Taran Douley

Posted on AI-assisted

How I found a CVSS 9.8 Critical in an OAuth-protected MCP server. And the one line fix.

Background

MCP (Model Context Protocol) is Anthropic's open standard for connecting LLM agents to external tools. A few of these tools include databases, file systems, APIs and shell access. MCP is only 18 months old, and the ecosystem is growing fast. This means very few of those servers have been looked at by anyone with a security background.

I've been auditing community MCP servers as part of research at Shroud Labs. This post covers the most interesting finding so far: a CVSS 9.8 Critical authentication bypass in a server explicitly advertising OAuth 2.1 protection.

The target

A TypeScript MCP server implementing the MCP spec's OAuth 2.1 authorization extension. Its entire purpose is to be an example of how to build a secure remote MCP server with proper bearer token authentication. It ships in two variants: a stateful version (maintains session state) and a stateless version (no session state, suitable for serverless deployments).

The README describes it as production-ready. It's installable and configured against a real Auth0 tenant.

The hunt

I built a specialised TLAD scanner (Transport-Layer Auth Divergence): a Semgrep ruleset I built to detect a specific pattern. This detects if auth middleware is applied to one transport variant but missing on another. I ran the scanner across 49 community HTTP MCP servers.

For this repo, it flagged both an authed mount and an unguarded mount in the same codebase. I manually checked for differences in the two files for the Layer 2 review. Took me 2 minutes.

app.stateful.ts line 150:

typescript
app.post("/mcp", bearerAuthMiddleware, async (req, res, next) => {

app.stateless.ts line 107:

typescript

app.post("/mcp", async (req: Request, res: Response, next: NextFunction) => {
Enter fullscreen mode Exit fullscreen mode

One argument. bearerAuthMiddleware is defined a few lines earlier in both files. It's used correctly in the stateful version. On the stateless version's /mcp route, it's completely missing.

The middleware was created. It was wired in everywhere else. It was just never passed to the one route that matters.

Why this is actually critical

bearerAuthMiddleware is what calls requireBearerAuth() — the function that validates the OAuth token. Without it in the middleware chain, the route handler runs regardless of what the caller sends.

That means:

No Authorization header → 200 OK
Fake bearer token → 200 OK
Revoked token → 200 OK
Someone else's token → 200 OK

The OAuth flow, the Auth0 tenant, the token issuance, the scope checking. It's all rendered irrelevant. The auth check never fires, and every tool exposed by the server is invocable by anyone who can reach the HTTP endpoint.

Dynamic confirmation

To be certain, I stood up the server locally with dummy OAuth credentials and ran two requests:

bash

Request 1: no token at all

curl -s -X POST http://localhost:5050/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

HTTP 200 — full tool list returned

Request 2: fake bearer token

curl -s -X POST http://localhost:5050/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer notarealtoken" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

HTTP 200 — full tool list returned again

The server logs confirmed both requests hit the route handler. Neither was rejected.

For comparison, the stateful version correctly returns 401 Unauthorized on both requests. Same codebase, different file, one missing argument.

CVSS 9.8

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

AV:N — the server binds to a network port for HTTP access. That's its whole purpose.
AC:L — no special conditions. Just send a request.
PR:N — no credentials required. Obviously.
UI:N — no user interaction.
C:H / I:H / A:H — complete access to all tools. Depending on what tools are configured, that's data access, writes, or arbitrary actions.

9.8 is the right score. The server is explicitly designed for network deployment.

The fix

One line, in app.stateless.ts:

typescript
// Before
app.post("/mcp", async (req, res, next) => {

// After
app.post("/mcp", bearerAuthMiddleware, async (req, res, next) => {

Identical to what the stateful version already does correctly. The middleware is already defined and imported. It just needs to be passed to this route.

The lesson

The bug lived in a diff between two files that were supposed to be equivalent in their security properties.

Security bugs aren't always exotic. Sometimes the control exists, is tested elsewhere, is working correctly on the other variant — and was simply never wired to the specific code path that runs in production.

When you're auditing a server that has multiple transport modes, multiple deployment variants, or multiple files implementing similar functionality: compare them. The gap between them is where the bug lives.

Rule: when two files should be identical in their security properties, diff them. The gap is the finding.

CVE and tooling

This was disclosed as CVE-2026-2035999 (CVSS 9.8 Critical).

The TLAD scanner that surfaced it is open source:

A companion general-purpose sink ruleset for MCP servers (mcp-sinks) covers command injection, path traversal, SSRF, SQLi, and deserialisation across Python and TypeScript.

GitHub logo ShroudLabs-io / mcp-sinks

Semgrep sink ruleset for MCP server security research — command injection, path traversal, SSRF, SQLi, deserialization, transport hygiene

mcp-sinks — Semgrep ruleset for MCP server vulnerability research

A two-tier Semgrep ruleset for auditing Model Context Protocol (MCP) servers for security vulnerabilities. Used to discover CVE-2026-2035922 (mac-shell-mcp) and a newline-separator allow-list bypass in cmd-line-mcp.

What it finds

Inventory rules (WARNING) — flags every dangerous sink so you can trace whether a tool argument reaches it. High recall, expect false positives.

Taint rules (ERROR) — MCP-aware taint analysis tracing tool handler arguments directly to dangerous sinks. Higher precision.

Audit rules — narrow, high-confidence patterns for specific known bypass shapes (a validator regex that omits \n, an execFile/spawn call whose shell option isn't a hard false), rather than a generic sink flag.

Covers six vulnerability classes across Python and JavaScript/TypeScript:

  • OS command injection (CWE-78)
  • Path traversal / sandbox escape (CWE-22)
  • Server-side request forgery (CWE-918)
  • SQL injection (CWE-89)
  • Insecure deserialisation (CWE-502)
  • Transport hygiene — 0.0.0.0 binding, TLS verification…

I surveyed 49 community HTTP MCP servers with these tools. Of the published packages with HTTP exposure in the sample, 100% had no inbound authentication. The ecosystem is young and largely unaudited. The tooling is now open.

Top comments (0)