Topic: mcp server xpath injection security
MCP server XPath injection security — blind extraction and doc() SSRF in XML-backed tools
XPath injection is the XML equivalent of SQL injection: an attacker inserts XPath operators and axis specifiers into a query that was intended to use their input as a literal string. On MCP servers that query SOAP services, configuration files, document stores, or XSLT pipelines, a single unsanitized tool argument enables boolean-based blind extraction of the entire XML document — plus server-side request forgery via the XPath doc() function.
How XPath injection works in an MCP tool handler
Consider an MCP server that exposes a find_config_entry tool. The tool takes a key argument and queries a local XML configuration file using an XPath expression:
// Vulnerable MCP tool handler
async function find_config_entry({ key }) {
const xml = fs.readFileSync('./config.xml', 'utf8');
const doc = new DOMParser().parseFromString(xml, 'text/xml');
// Vulnerable: key inserted directly into XPath string
const expr = `/config/entry[@key='${key}']`;
const result = xpath.select(expr, doc);
return result.map(n => n.textContent).join('\n');
}
An attacker passes key = "' or '1'='1. The resulting expression becomes:
/config/entry[@key='' or '1'='1']
This matches every entry element in the document, regardless of its key attribute. The tool returns the text content of all configuration entries, including API keys, database credentials, and internal service URLs that the caller was not supposed to see.
Boolean-based blind extraction
When a tool's output is binary (found/not-found) rather than direct text, an attacker uses boolean-based blind injection to extract data one bit at a time. The technique mirrors SQL blind injection but uses XPath string functions:
# Extract the first character of the first password element
# Probe: is the ASCII code of the first char of password[1] > 64 (i.e., >= 'A')?
key = "' or substring(//password[1], 1, 1) > 'A' or 'x'='y
# If the tool returns a result → char > 'A'
# Binary search across the printable ASCII range extracts each character in ~7 queries
# A 20-character password requires ~140 tool invocations
An LLM agent with access to the vulnerable tool can execute this extraction automatically, iterating through character positions and binary-searching the ASCII range. The entire XML document can be exfiltrated through a side channel that produces no error, only variation in the number of returned results.
doc() SSRF — server-side request forgery via XPath
XPath 1.0 and 2.0 both include the doc() function, which loads an external XML document by URL and makes it available for querying. On XSLT-capable XML processors (Saxon, Xalan, libxslt) and XML-heavy data tools, injecting a doc() call turns XPath injection into SSRF:
# Injected key value:
' or doc('http://169.254.169.254/latest/meta-data/iam/security-credentials/role')//Code='Success
# Resulting XPath:
/config/entry[@key='' or doc('http://169.254.169.254/latest/meta-data/iam/security-credentials/role')//Code='Success']
# The XML processor fetches the AWS metadata endpoint and queries it.
# If the query returns true (Code = 'Success' in the metadata), the tool returns a result.
# Binary search on the returned credential fields exfiltrates the IAM role credentials.
This turns a configuration lookup tool into a full SSRF probe against internal services, cloud metadata endpoints, and anything reachable from the server's network. No HTTP library call in the MCP server code is involved — the SSRF originates from the XML processor itself.
Prevention: parameterized XPath with variable binding
The definitive fix is variable binding — the XPath equivalent of parameterized SQL queries. Instead of concatenating the user-supplied value into the XPath string, bind it as a typed variable that the processor treats as a literal string operand, never as XPath syntax:
// Node.js — parameterized XPath using xpath library variable resolver
import xpath from 'xpath';
import { DOMParser } from '@xmldom/xmldom';
async function find_config_entry({ key }) {
const xml = fs.readFileSync('./config.xml', 'utf8');
const doc = new DOMParser().parseFromString(xml, 'text/xml');
// Safe: key is bound as a string variable, never interpolated
const select = xpath.useNamespaces({});
const expr = "/config/entry[@key=$userKey]";
const resolver = {
mappings: { userKey: key },
lookupVariable(name) {
// Return the variable as a string literal — no XPath parsing
if (name in this.mappings) {
return xpath.createExpression(
`'${this.mappings[name].replace(/'/g, "'")}'`, null
);
}
}
};
// Use the xpath library's select with a variable context
const result = xpath.select(expr, doc, null, true, resolver);
return Array.isArray(result)
? result.map(n => n.textContent).join('\n')
: '';
}
For Python, lxml provides the same capability via the XPath class with smart_strings=False and the keyword argument form:
# Python — lxml parameterized XPath
from lxml import etree
tree = etree.parse('config.xml')
find_entry = etree.XPath("/config/entry[@key=$userKey]")
# userKey is passed as a keyword argument — lxml treats it as an atomic string
results = find_entry(tree, userKey=key)
return [r.text for r in results]
Disabling doc() and external entity resolution
Even with parameterized queries, disable external document loading at the XML processor level as defense in depth. In Node.js with @xmldom/xmldom, the parser does not support doc() by default. In Java with JAXP:
// Java — disable external DTD and doc() in XPath processor
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
In Python with lxml, set resolve_entities=False and use XMLParser(no_network=True, resolve_entities=False) to block all external URL resolution at the parser level.
Input validation as a secondary layer
For tools where the key space is bounded (known enumeration, UUID format, alphanumeric identifiers), validate the input format before constructing any query. Reject inputs that contain XPath metacharacters (', ", [, ], @, /, (, )) unless they are explicitly valid in the domain:
function validateConfigKey(key) {
if (typeof key !== 'string') throw new Error('key must be a string');
if (key.length > 128) throw new Error('key too long');
// Only alphanumeric and hyphens for config keys
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
throw new Error('key contains invalid characters');
}
return key;
}
Input validation is a secondary control — it protects against XPath injection in tools with bounded key spaces, but it is not a replacement for parameterized XPath. Implement both.
SkillAudit checks for XPath injection risk
SkillAudit's static analysis checks for string concatenation into XPath expressions, XPath library calls with unbound arguments, and XML parser configurations that permit external entity resolution or doc() fetching. An A-grade MCP server uses variable binding for all XPath queries that incorporate tool arguments, and disables external resolution at the processor level. See the full MCP server security checklist for the complete injection-prevention requirements.
Check your MCP server for injection vulnerabilities
SkillAudit checks for XPath, SQL, NoSQL, and shell injection patterns in MCP tool handlers in 60 seconds.
Run a free audit