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
| Finding | Severity | Grade impact |
|---|---|---|
yaml.load() with DEFAULT_SCHEMA on user input | Critical | −20 |
| No input size limit before YAML parsing | High | −10 |
| No schema validation after YAML parsing | Medium | −5 |
| No prototype-key sanitization after parsing | Medium | −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.