MCP Server Security

YAML injection security in MCP servers

MCP tools that accept YAML input — configuration files, pipeline definitions, structured data — face a class of vulnerabilities distinct from JSON: YAML's richer feature set (custom tags, anchors, merge keys) creates attack vectors that range from arbitrary JavaScript execution to gigabyte memory exhaustion via a 50-byte payload.

The js-yaml load() code execution vulnerability

The most severe YAML vulnerability in Node.js MCP servers is using js-yaml's load() function (or the old safeLoad-less API) with untrusted input. YAML has a concept of custom tags that can invoke arbitrary JavaScript in some parsers. The js-yaml library's DEFAULT_SCHEMA includes the !!js/undefined, !!js/regexp, and !!js/function tags:

import yaml from 'js-yaml'

// Dangerous — DEFAULT_SCHEMA allows !!js/* tags
const result = yaml.load(userInput)

// An attacker sends:
// foo: !!js/function "function(){ require('child_process').execSync('rm -rf /') }"
// On older js-yaml versions (<4.x), this executes arbitrary code

// Safe — use CORE_SCHEMA or JSON_SCHEMA, which forbid !!js/* tags
const safe = yaml.load(userInput, { schema: yaml.CORE_SCHEMA })

// Alternatively, use safeLoad (js-yaml <4.x) or the default in >=4.x
// js-yaml 4.0+ changed the default to CORE_SCHEMA — check your version

The fix: always specify schema: yaml.CORE_SCHEMA explicitly, regardless of your js-yaml version. This future-proofs against schema default changes and makes the security intent explicit to code reviewers.

YAML bomb — billion laughs DoS

The YAML billion laughs attack uses YAML anchors and aliases to create exponentially expanding structures. A 50-byte YAML payload can expand to gigabytes of memory when parsed:

# 50 bytes — expands to ~10^9 strings when parsed
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]

Most YAML parsers (including js-yaml) do not have a built-in limit on alias expansion. Parsing this input will exhaust available memory and crash the Node.js process.

Defense: limit the input size before parsing, and set a parse timeout using a Worker thread:

import { Worker } from 'node:worker_threads'
import yaml from 'js-yaml'

const MAX_YAML_BYTES = 64 * 1024  // 64KB is generous for config files

async function parseYamlSafely(input: string): Promise<unknown> {
  // Size guard — before any parsing
  if (Buffer.byteLength(input, 'utf-8') > MAX_YAML_BYTES) {
    throw new Error(`YAML input too large (max ${MAX_YAML_BYTES} bytes)`)
  }

  // Parse in a Worker thread with a timeout to limit memory and CPU
  return new Promise((resolve, reject) => {
    const code = `
      const { workerData, parentPort } = require('worker_threads')
      const yaml = require('js-yaml')
      try {
        const result = yaml.load(workerData.input, { schema: yaml.CORE_SCHEMA })
        parentPort.postMessage({ ok: true, result })
      } catch (e) {
        parentPort.postMessage({ ok: false, error: e.message })
      }
    `
    const worker = new Worker(code, { eval: true, workerData: { input } })
    const timer = setTimeout(() => {
      worker.terminate()
      reject(new Error('YAML parse timeout — possible billion laughs attack'))
    }, 500)  // 500ms is generous for any legitimate YAML under 64KB

    worker.on('message', ({ ok, result, error }) => {
      clearTimeout(timer)
      if (ok) resolve(result)
      else reject(new Error(`YAML parse error: ${error}`))
    })
    worker.on('error', (err) => { clearTimeout(timer); reject(err) })
  })
}

Prototype pollution via YAML merge keys

YAML's merge key (<<:) allows merging one mapping into another. Some YAML parsers pass the __proto__ key through the merge, which can pollute Object.prototype the same way a recursive JavaScript merge does:

# Malicious YAML input
base: &base
  __proto__:
    isAdmin: true

target:
  <<: *base
  name: "payload"

# After parsing, every {} in the process may have isAdmin: true

Defense: after parsing YAML, sanitize the result by recursively removing __proto__ and constructor keys before using the object:

function sanitizeYamlResult(obj: unknown): unknown {
  if (typeof obj !== 'object' || obj === null) return obj
  if (Array.isArray(obj)) return obj.map(sanitizeYamlResult)

  const sanitized: Record<string, unknown> = Object.create(null)
  for (const key of Object.keys(obj as Record<string, unknown>)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
    sanitized[key] = sanitizeYamlResult((obj as Record<string, unknown>)[key])
  }
  return sanitized
}

// Usage
const raw = yaml.load(input, { schema: yaml.CORE_SCHEMA })
const safe = sanitizeYamlResult(raw)

Schema validation after parsing

After safe parsing, validate the resulting object against your expected schema with Zod before using it. YAML's flexible types (strings parsed as booleans, numbers with scientific notation, null synonyms) can produce unexpected values even from well-intentioned input:

import { z } from 'zod'

const PipelineConfigSchema = z.object({
  name: z.string().max(128),
  steps: z.array(z.object({
    action: z.enum(['scan', 'lint', 'test', 'deploy']),
    timeout: z.number().int().min(1).max(3600).optional(),
  })).max(20),
  environment: z.record(z.string(), z.string()).optional(),
})

// Full safe parsing pipeline:
const rawYaml = await parseYamlSafely(userInput)       // size + timeout + worker
const sanitized = sanitizeYamlResult(rawYaml)          // prototype pollution guard
const config = PipelineConfigSchema.parse(sanitized)   // type + schema validation

Safer alternatives when YAML is not required

If your MCP tool accepts config but doesn't specifically require YAML, prefer JSON. JSON is a strict subset with no tags, no anchors, no merge keys, and no billion laughs attack class. If users need comments or less-strict syntax, consider JSONC or TOML, both of which have simpler and better-understood security profiles than YAML.

SkillAudit grading for YAML security

FindingSeverityGrade impact
yaml.load() with DEFAULT_SCHEMA on user inputCritical−20
No input size limit before YAML parsingHigh−10
No schema validation after YAML parsingMedium−5
No prototype-key sanitization after parsingMedium−5
YAML parsed in Worker with timeout+4
CORE_SCHEMA specified explicitly+3
Zod validation applied to parsed YAML output+3

Scan your MCP server for YAML vulnerabilities

SkillAudit's static scanner identifies yaml.load() calls without explicit schema specification and YAML usage without upstream size limiting. Run a free audit and get line-level findings in under 60 seconds.