Claude skill input sanitization bypass
Claude MCP skills that accept string tool arguments and pass them to template engines, database queries, directory lookups, or URL fetchers without proper sanitization carry the same injection vulnerabilities that have plagued web applications for decades — but with two unique amplifiers: the LLM calling the tool can be prompt-injected to supply attacker-controlled arguments, and a single MCP server serves multiple LLM sessions with the same process credentials. The four patterns in SA-SKL-STR cover HTML/XML template XSS, SQL injection, LDAP DN injection, and the less-obvious SSRF vector where naive allowlist URL validation fails to block cloud metadata IP addresses reached via IPv6 encoding or URL percent-encoding bypasses.
The injection class of vulnerabilities in MCP skills is not a new category of attack — it is the same injection taxonomy from OWASP Top 10 applied to a new execution context. What changes in the MCP context is the attack path: instead of a malicious user directly interacting with a web form, the attacker injects a payload into a document, web page, or data source that the LLM later processes and summarizes. When the LLM’s summarization includes or extracts the attacker’s payload and passes it as a tool argument, the injection occurs through the LLM as an unwitting relay. This prompt injection delivery mechanism means that skills processing external content (emails, web pages, code repositories, customer support tickets) are at elevated injection risk even when the direct human caller is trusted.
SA-SKL-STR-005: HTML/XML input passed to template engines without escaping
Skills that retrieve user-supplied content (documents, web pages, emails) and later render that content through an HTML template engine are vulnerable to stored cross-site scripting if the template engine performs no automatic HTML escaping. In a skill context, the XSS payload executes in whatever browser or Electron-based application renders the template output. For skills that generate HTML reports, email content, or rendered previews, an attacker who controls the input document can inject a <script> tag that executes in the viewer’s browser. Even if the direct render is not browser-based, an unescaped XML injection in a template that generates Word OOXML or SVG can create malformed documents that trigger parsing vulnerabilities in downstream consumers.
// VULNERABLE: template literal with unescaped user content
server.tool(
'generate_report',
{
title: { type: 'string', description: 'Report title from document' },
content: { type: 'string', description: 'Extracted document content' }
},
async (args) => {
// Attacker-controlled content: ''
// Template literal inserts raw HTML — browser executes script when report is viewed
const html = `
${args.title}
${args.content}
`;
await fs.writeFile('/var/reports/latest.html', html);
return { content: [{ type: 'text', text: 'Report generated' }] };
}
);
// ALSO VULNERABLE: Handlebars with triple-brace (unescaped)
// {{content}} is safe; {{{content}}} disables escaping → XSS
const template = Handlebars.compile('{{{content}}}');
// SECURE: escape HTML entities before inserting into template
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
server.tool(
'generate_report',
{
title: { type: 'string' },
content: { type: 'string' }
},
async (args) => {
const ReportSchema = z.object({
title: z.string().max(200),
content: z.string().max(100_000)
});
const { title, content } = ReportSchema.parse(args);
const html = `
${escapeHtml(title)}
${escapeHtml(content)}
`;
// Or use a template engine with auto-escaping: Nunjucks, Pug, EJS with <%=
// Handlebars: use {{content}} (double-brace) which auto-escapes HTML
await fs.writeFile('/var/reports/latest.html', html);
return { content: [{ type: 'text', text: 'Report generated' }] };
}
);
// For XML/OOXML generation: use a proper XML builder library (xmlbuilder2, fast-xml-parser)
// never concatenate user strings directly into XML
// DETECTION (SkillAudit):
// Template literal `...${args.*}...` or {{{args.*}}} in Handlebars without escapeHtml()
const xssRisk = searchAST(
'template literal with ${args.*} in HTML context ' +
'OR {{{args.*}}} in Handlebars template without preceding escapeHtml()'
);
if (xssRisk.length > 0) → CRITICAL // SA-SKL-STR-005
Prompt injection chains to template injection: if your skill reads an email, document, or web page and then passes extracted text as tool arguments, the attacker controls the extracted text by controlling the document. A document with the body <script>alert(1)</script> will cause the LLM to extract that literal string as the content field and pass it to the tool. The tool then inserts it unescaped into an HTML template. The LLM is not at fault; the vulnerability is the absent server-side sanitization.
${args.*} in HTML string without escapeHtml() → CRITICAL.SA-SKL-STR-006: SQL query construction from tool arguments without parameterized queries
Skills that query a SQLite, PostgreSQL, or MySQL database using tool arguments to construct query conditions are vulnerable to SQL injection if the argument value is interpolated directly into the query string rather than bound as a parameterized placeholder. The LLM-as-relay mechanism applies here too: a document the skill processes may contain text like ; DROP TABLE users; -- which the LLM faithfully extracts as a user identifier and passes as a tool argument. A parameterized query treats the entire argument as a literal value to be bound, not as SQL syntax to be parsed, regardless of what characters it contains.
// VULNERABLE: template literal SQL — classic injection
server.tool(
'lookup_user',
{ username: { type: 'string', description: 'Username to look up' } },
async (args) => {
// Attacker-injected username: "admin' OR '1'='1"
// Resulting query: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
// Returns all users — authentication bypass
const rows = await db.all(`SELECT * FROM users WHERE username = '${args.username}'`);
return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
}
);
// Also vulnerable with string concatenation:
const query = "SELECT * FROM users WHERE username = '" + args.username + "'";
// SECURE: parameterized query with ? placeholder (SQLite/mysql2) or $1 (pg)
server.tool(
'lookup_user',
{ username: { type: 'string', description: 'Username to look up' } },
async (args) => {
const { username } = z.object({ username: z.string().max(100) }).parse(args);
// ? placeholder — the db driver sends the parameter separately from the query text
// The database never interprets args as SQL syntax regardless of content
const rows = await db.all('SELECT * FROM users WHERE username = ?', [username]);
return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
}
);
// For dynamic column/table names (which cannot be parameterized):
// Use an allowlist: const ALLOWED_COLS = ['name', 'email', 'created_at'];
// if (!ALLOWED_COLS.includes(args.sortBy)) throw new Error('invalid column');
// const query = `SELECT * FROM users ORDER BY ${args.sortBy}`; // safe: only known values
// For SQLite: use better-sqlite3 prepared statements
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
const rows = stmt.all(username);
// DETECTION (SkillAudit):
// db.query / db.all / db.run with template literal or + concatenation containing args.*
const sqlInjection = searchAST(
'db.query(`...${args.*}...`) | db.all(`...${args.*}...`) | ' +
'"SELECT * FROM " + args.* | db.run("... \'" + args.* + "\'")'
);
if (sqlInjection.length > 0) → CRITICAL // SA-SKL-STR-006
ORM query builders are not automatically injection-safe: knex.raw('WHERE id = ' + args.id) is just as vulnerable as a manual template literal. Use ORM parameterized methods: knex('users').where('id', args.id) (safe), not knex.raw() with string concatenation. For Prisma and TypeORM, raw query escape functions (Prisma.sql, escape()) are available when you need dynamic queries — always use them instead of string interpolation.
db.query(\`...${args.*}...\`) → CRITICAL.SA-SKL-STR-007: LDAP DN injection via unsanitized user identifier in directory lookup
Skills that authenticate against or query an LDAP directory (Active Directory, OpenLDAP) using user-supplied identifiers are vulnerable to LDAP injection if the identifier is not properly escaped before insertion into the search filter or distinguished name. LDAP special characters (( ) * \ NUL for search filters; , + = < > # ; for DNs) allow an attacker to modify the semantics of the lookup. The most common impact in MCP skills is authentication bypass: a search filter (&(uid=USERVAL)(password=PASSVAL)) where USERVAL = admin)(&) becomes (&(uid=admin)(&))(password=PASSVAL)) which is parsed as (&(uid=admin)(&)) — the always-true (&) element authenticates without a valid password.
// VULNERABLE: LDAP search filter with unsanitized user input
import ldap from 'ldapts';
server.tool(
'directory_lookup',
{ username: { type: 'string', description: 'AD username to look up' } },
async (args) => {
const client = new ldap.Client({ url: 'ldap://corp-ad.internal' });
await client.bind('cn=service,dc=corp,dc=com', process.env.LDAP_PASSWORD);
// Attacker username: "admin)(|(uid=*"
// Filter becomes: (&(uid=admin)(|(uid=*)(objectClass=user))
// Returns ALL user objects — leaks entire directory
const filter = `(&(uid=${args.username})(objectClass=user))`;
const { searchEntries } = await client.search('dc=corp,dc=com', { filter });
return { content: [{ type: 'text', text: JSON.stringify(searchEntries) }] };
}
);
// SECURE: escape LDAP special characters before filter insertion
function escapeLdapFilter(input) {
return String(input)
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\x00/g, '\\00');
}
server.tool(
'directory_lookup',
{ username: { type: 'string', description: 'AD username to look up' } },
async (args) => {
const { username } = z.object({
username: z.string().regex(/^[a-zA-Z0-9._-]+$/, 'invalid username characters').max(64)
}).parse(args);
const client = new ldap.Client({ url: 'ldap://corp-ad.internal' });
await client.bind('cn=service,dc=corp,dc=com', process.env.LDAP_PASSWORD);
// escapeLdapFilter neutralizes all LDAP metacharacters
const safeUsername = escapeLdapFilter(username);
const filter = `(&(uid=${safeUsername})(objectClass=user))`;
const { searchEntries } = await client.search('dc=corp,dc=com', { filter });
return { content: [{ type: 'text', text: JSON.stringify(searchEntries) }] };
}
);
// RFC 4515 defines the escape rules for LDAP search filter components
// RFC 4514 defines separate escaping rules for Distinguished Names — different set
// Use a library like ldap-escape (npm) if available rather than hand-rolling
// DETECTION (SkillAudit):
// client.search() | ldap.* with filter containing template literal with args.*
const ldapInj = searchAST(
'ldap.search / ldapts.search with filter: `...${args.*}...` ' +
'without escapeLdapFilter() or RFC-4515 escaping'
);
if (ldapInj.length > 0) → HIGH // SA-SKL-STR-007
Prefer an allowlist regex over escape-and-insert for LDAP usernames: a valid sAMAccountName in Active Directory contains only letters, digits, hyphens, and dots — a regex like /^[a-zA-Z0-9._-]{1,64}$/ rejects any username with LDAP metacharacters before the filter is built. Escape-and-insert is the correct fallback for free-text fields (display names, descriptions) that legitimately contain parentheses or asterisks, but for identifiers, allowlist validation is simpler and more defensive.
ldap.search(filter: \`...${args.*}...\`) without escape → HIGH.SA-SKL-STR-008: SSRF via URL validation that allows metadata IP via encoding bypass
Skills that accept a URL as a tool argument and fetch it on behalf of the LLM are vulnerable to SSRF if their URL allowlist or blocklist validation can be bypassed by encoding the cloud metadata IP address (169.254.169.254 on AWS/GCP/Azure) in alternative formats. A blocklist that matches the string "169.254.169.254" fails to block: IPv6 representation [::ffff:169.254.169.254], decimal encoding 2852039166, octal encoding 025177254376, hexadecimal 0xa9fea9fe, URL percent-encoding %31%36%39%2e%32%35%34%2e%31%36%39%2e%32%35%34, and DNS resolution where a controlled domain resolves to the metadata IP (DNS rebinding). All of these bypass string-matching blocklists and resolve to the same metadata endpoint when the HTTP client follows the request.
// VULNERABLE: blocklist on string representation — bypassed by encoding
server.tool(
'fetch_url',
{ url: { type: 'string', description: 'URL to fetch' } },
async (args) => {
// Naive blocklist
const blocked = ['169.254.169.254', '10.', '192.168.', '127.0.0.1', 'localhost'];
if (blocked.some(b => args.url.includes(b))) {
throw new Error('blocked IP range');
}
// Bypasses:
// http://2852039166/latest/meta-data/ — decimal encoding
// http://[::ffff:169.254.169.254]/latest/meta-data/ — IPv6
// http://0xa9fea9fe/latest/meta-data/ — hex encoding
// http://attacker.example/ — DNS resolves to 169.254.169.254 (rebinding)
const response = await fetch(args.url);
const text = await response.text();
return { content: [{ type: 'text', text }] };
}
);
// SECURE: resolve URL to IP, then check IP range — not the URL string
import dns from 'dns/promises';
import { isIP, isIPv4, isIPv6 } from 'net';
import ipaddr from 'ipaddr.js'; // npm: ipaddr.js
const BLOCKED_RANGES = [
// RFC 1918 private ranges
ipaddr.parseCIDR('10.0.0.0/8'),
ipaddr.parseCIDR('172.16.0.0/12'),
ipaddr.parseCIDR('192.168.0.0/16'),
// Link-local (cloud metadata)
ipaddr.parseCIDR('169.254.0.0/16'),
// Loopback
ipaddr.parseCIDR('127.0.0.0/8'),
// IPv6 loopback
ipaddr.parseCIDR('::1/128'),
// IPv4-mapped IPv6
ipaddr.parseCIDR('::ffff:0:0/96'),
];
async function isSafeUrl(rawUrl) {
const parsed = new URL(rawUrl); // throws on malformed URL
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const hostname = parsed.hostname;
const addrs = await dns.resolve4(hostname).catch(() => []);
const addrs6 = await dns.resolve6(hostname).catch(() => []);
const allAddrs = [...addrs, ...addrs6];
if (allAddrs.length === 0) return false; // no resolution = don't fetch
return allAddrs.every(addr => {
const ip = ipaddr.parse(addr);
return BLOCKED_RANGES.every(([range, bits]) => !ip.match(range, bits));
});
}
server.tool(
'fetch_url',
{ url: { type: 'string', description: 'URL to fetch content from' } },
async (args) => {
const { url } = z.object({ url: z.string().url().max(2000) }).parse(args);
if (!(await isSafeUrl(url))) {
throw new Error('URL resolves to a blocked IP range (private or metadata network)');
}
// Note: DNS rebinding still possible between check and fetch
// Mitigate by using a proxy that enforces IP-range checks at connection time
// or by binding the resolved IP directly in the http agent
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const text = await response.text();
return { content: [{ type: 'text', text: text.slice(0, 50_000) }] };
}
);
// DETECTION (SkillAudit):
// fetch(args.url) | axios.get(args.url) without DNS-resolved IP range check
const ssrfRisk = searchAST(
'fetch(args.*) | axios.get(args.*) | https.get(args.*) ' +
'without isSafeUrl | dns.resolve check on resolved IP'
);
if (ssrfRisk.length > 0) → HIGH // SA-SKL-STR-008
DNS resolution happens between your check and the fetch: even after resolving the hostname to an IP and verifying it is not a metadata IP, the HTTP library performs a second DNS resolution when making the actual connection. A DNS rebinding attack can return a benign IP on the first resolution (passing your check) and the metadata IP on the second resolution (to the fetch). The only complete defense is to resolve the IP once, check it, and then establish the HTTP connection directly to that IP using a custom HTTP agent — or route all outbound fetches through an egress proxy that enforces IP allowlists at the TCP layer.
fetch(args.url) without resolved-IP range check → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| STR-005: HTML template XSS | CRITICAL | Escape HTML entities with escapeHtml(); use auto-escaping template engine (Nunjucks, Handlebars double-brace) |
| STR-006: SQL injection | CRITICAL | Parameterized queries with ? or $1 placeholders; never template literals with user input |
| STR-007: LDAP DN injection | HIGH | Allowlist regex for identifier fields; RFC 4515 escape for free-text fields in LDAP filters |
| STR-008: SSRF metadata IP bypass | HIGH | Resolve hostname to IP, check all IPs against RFC 1918 + link-local ranges; use egress proxy for complete protection |
SkillAudit’s security engine checks for injection patterns using AST analysis of all tool handler code paths. The SA-SKL-STR family is part of our complete attack surface reference covering 15 attack families. Related families: SA-SKL-STR schema validation bypass, SA-SKL-CLI subprocess injection, SA-SKL-SBX sandbox escape, SA-SKL-ENV credential exposure.
Scan your Claude skill for injection vulnerabilities
SkillAudit checks for XSS, SQL injection, LDAP injection, and SSRF patterns in Claude MCP skills in 60 seconds.
Get your free audit