MCP Server Security

Regular expression security in MCP servers: ReDoS and safe patterns

A single regex applied to attacker-controlled input can hang your MCP server's event loop for seconds or minutes — blocking all concurrent tool calls and creating a denial-of-service condition. ReDoS is subtle because the vulnerable regex looks completely normal; the exploit is the input, not the code.

What makes a regex vulnerable to ReDoS

ReDoS (Regular Expression Denial of Service) exploits catastrophic backtracking: a regex engine tries exponentially many ways to match a carefully crafted string before failing. The classic trigger is a pattern with two overlapping repetition quantifiers applied to a common character class, combined with input that almost-but-not-quite matches:

// Vulnerable patterns — catastrophic backtracking
const EMAIL_PATTERN = /^([a-zA-Z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$/
// Input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@"
// The regex engine tries every partition of the 'a' sequence across the groups
// before deciding it can't match — O(2^n) attempts for n 'a' characters

const HTML_COMMENT = //s
// With nested loops: /^(a+)+$/  is the canonical catastrophic example
const NESTED_QUANTIFIER = /^(a+)+$/ // purely educational — never use this
// Input: "aaaaaaaaaaaaaaaaaaaaaaab" — exponential backtracking

The danger class is: nested quantifiers ((a+)+, (a*)*), alternation with overlap ((a|aa)+), and possessive or greedy quantifiers on a broad character class when the match can fail.

Why MCP servers are especially vulnerable

MCP tools often validate string inputs with regex — email addresses, URLs, search queries, identifiers. The LLM intermediary means an attacker can craft a tool argument that is syntactically valid (passes schema parsing) but designed to trigger catastrophic backtracking. Unlike a human-facing form where the payload needs to survive URL encoding, tool arguments arrive as raw JSON strings — the attacker has full control over the character composition.

Node.js runs regex in the same event loop as your MCP handlers. A 5-second regex hang blocks all concurrent sessions for 5 seconds. A 30-second hang looks like a server crash to the client.

Detecting vulnerable regex at development time

Use safe-regex or recheck to lint your regex patterns during CI:

npm install --save-dev safe-regex recheck
import safeRegex from 'safe-regex'
import { check } from 'recheck'

const patterns = [
  /^([a-zA-Z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$/,
  /^https?:\/\/([\w-]+\.)+[\w-]+(\/[\w./?%&=-]*)?$/,
]

for (const pattern of patterns) {
  const isSafe = safeRegex(pattern)
  const recheckResult = check(pattern.source, pattern.flags)

  if (!isSafe || recheckResult.status === 'vulnerable') {
    console.error(`UNSAFE REGEX: ${pattern} — ${recheckResult.attack?.pattern}`)
    process.exit(1)
  }
}

Run this check in CI as part of your test suite. recheck is more precise than safe-regex — it uses static analysis to find the actual attack string, not just a heuristic classification.

Timeout-based regex execution

For patterns you can't rewrite (legacy validation logic, third-party patterns), wrap execution in a Worker thread with a timeout:

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'

function testRegexWithTimeout(
  pattern: string,
  flags: string,
  input: string,
  timeoutMs = 100
): Promise<boolean> {
  return new Promise((resolve, reject) => {
    const code = `
      const { pattern, flags, input } = workerData
      const re = new RegExp(pattern, flags)
      parentPort.postMessage(re.test(input))
    `
    const worker = new Worker(code, {
      eval: true,
      workerData: { pattern, flags, input }
    })

    const timer = setTimeout(() => {
      worker.terminate()
      reject(new Error('Regex timeout — potential ReDoS input'))
    }, timeoutMs)

    worker.on('message', (result) => {
      clearTimeout(timer)
      resolve(result as boolean)
    })

    worker.on('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
  })
}

The worker thread runs on a separate OS thread — if it hangs, the main event loop is unaffected, and the timeout terminates it cleanly. The 100ms default is generous; most legitimate inputs match in under 1ms.

Linear-time alternatives for common validation patterns

Many common MCP input validation patterns can be rewritten without catastrophic-backtracking risk:

Email validation — avoid regex entirely

// Bad: complex regex with nested quantifiers
const EMAIL_RE = /^([a-zA-Z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$/

// Good: minimal structural check — let the email server reject truly invalid addresses
function isPlausibleEmail(s: string): boolean {
  if (s.length > 254) return false
  const atIdx = s.lastIndexOf('@')
  if (atIdx < 1 || atIdx === s.length - 1) return false
  const local = s.slice(0, atIdx)
  const domain = s.slice(atIdx + 1)
  return local.length <= 64 && domain.includes('.') && /^[\w.!#$%&'*+/=?^`{|}~-]+$/.test(local)
}

URL validation — use the URL constructor

// Bad: regex that tries to parse URL structure
const URL_RE = /^https?:\/\/([\w-]+\.)+[\w-]+(\/[\w./?%&=-]*)?$/

// Good: the URL constructor does exact parsing, O(n)
function isValidHttpUrl(s: string): boolean {
  try {
    const url = new URL(s)
    return url.protocol === 'https:' || url.protocol === 'http:'
  } catch {
    return false
  }
}

Identifier validation — simple character class only

// Bad: (word)+ patterns are vulnerable to long strings of word chars followed by non-word
const ID_RE = /^([a-z0-9]+[-_]?)+[a-z0-9]+$/

// Good: single quantifier, anchored, length-bounded
function isValidIdentifier(s: string): boolean {
  return s.length <= 64 && /^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(s)
}

Input length bounding as a defence-in-depth control

All string inputs that pass through regex validation should be length-bounded before the regex runs. Most catastrophic backtracking attacks require inputs of 50+ characters to achieve multi-second hangs:

function validateEmail(raw: unknown): string {
  if (typeof raw !== 'string') throw new Error('Expected string')
  if (raw.length > 254) throw new Error('Email too long')   // ← bound BEFORE regex
  if (!isPlausibleEmail(raw)) throw new Error('Invalid email format')
  return raw
}

SkillAudit grading for regex security

FindingSeverityGrade impact
Catastrophic backtracking pattern on user input (confirmed by recheck)High−12
Complex validation regex with no input length boundMedium−5
Email/URL parsed by hand-written regex (vs URL constructor or structural check)Low−3
safe-regex/recheck linting present in CI+4
All string inputs length-bounded before validation+3

Scan your MCP server for ReDoS risks

SkillAudit's static scanner identifies regex patterns that safe-regex classifies as unsafe and correlates them with tool handler inputs. Run a free audit to see which patterns in your tool handlers are ReDoS candidates.