← Blog

Claude skill middleware and plugin injection

Claude skills that support extensibility through configurable middleware or plugin paths expose four attack patterns in the SA-SKL-MID family: arbitrary code loading via require(config.middlewarePath), Express-style middleware chains that let user-supplied headers override authentication properties, eval-equivalent plugin loading from tool arguments, and full request body logging that sends PII and credentials to SIEM pipelines in production.

By SkillAudit  |  2 Nov 2026

MCP skills that serve as platform foundations — providing authentication, routing, logging, and transformation as shared infrastructure — often implement their own middleware or plugin systems to allow operators to customise behaviour without forking the skill codebase. Middleware paths come from configuration files, environment variables, or tool arguments; plugins are loaded from a directory specified by the operator. When any part of these load paths is influenced by user-supplied tool arguments or by headers that reach the skill through an HTTP proxy layer, attackers can load arbitrary code, inject fake authentication properties, and suppress error signals that monitoring systems depend on.

SA-SKL-MID-001: require() from user-configurable middleware path enables arbitrary code load

Skills that read a middleware module path from a config file or tool argument and pass it to require() allow any operator or attacker with write access to the config to load arbitrary Node.js code. If the config file is writable by the user running the MCP host process (the typical deployment), or if the tool argument is not validated against an allowlist, the attacker supplies a path to a file they control. That file executes with the full privileges of the skill process at the moment the skill is started or when the hot-reload path is triggered. The compromise is permanent: the loaded module persists in Node.js module cache for the lifetime of the process, and unloading it requires a process restart.

// VULNERABLE: middleware loaded from config.middlewarePath without validation
import config from './config.json' assert { type: 'json' };

// config.json might contain:
// { "middlewarePath": "/tmp/attacker.js" }
// or derived from tool argument: req.params.arguments.middlewareModule

const middleware = require(config.middlewarePath);  // ← arbitrary file load

// Express-style skill with configurable middleware
const app = new SkillApp();
app.use(middleware);  // loads and runs attacker code

// ALSO VULNERABLE: dynamic import from tool argument
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'load_extension') {
    const { extensionPath } = req.params.arguments;
    const ext = await import(extensionPath);  // ← arbitrary module load from LLM argument
    ext.setup(server);
    return { content: [{ type: 'text', text: 'loaded' }] };
  }
});

// SECURE: load only from a pre-resolved allowlist of bundled modules
const BUNDLED_MIDDLEWARE = {
  'rate-limit':    () => import('./middleware/rate-limit.js'),
  'auth-jwt':      () => import('./middleware/auth-jwt.js'),
  'request-id':   () => import('./middleware/request-id.js'),
  'cors':         () => import('./middleware/cors.js'),
};

async function loadMiddleware(name) {
  if (!Object.hasOwn(BUNDLED_MIDDLEWARE, name)) {
    throw new Error(`Unknown middleware '${name}'. Available: ${Object.keys(BUNDLED_MIDDLEWARE).join(', ')}`);
  }
  return (await BUNDLED_MIDDLEWARE[name]()).default;
}

// Middleware names from config are validated against this map
// Arbitrary paths are never passed to require() or import()

// DETECTION (SkillAudit):
// require(expr) or import(expr) where expr derives from config file fields,
// environment variables, or tool arguments — not a literal string
const dynamicRequire = searchAST(
  'require(non-literal) | import(non-literal) where non-literal derives from ' +
  'config.*, process.env.*, or req.params.arguments.*'
);
if (dynamicRequire.length > 0) → CRITICAL  // SA-SKL-MID-001

Config files are part of the attack surface: an attacker with write access to the config file — through a path traversal vulnerability elsewhere, a misconfigured file permission, or a supply chain compromise of a config management tool — can load arbitrary code by setting middlewarePath to a file they control. If the config file is stored in a shared directory, version controlled without access controls, or readable/writable by multiple services, the risk extends beyond the immediate skill codebase.

CRITICAL
SA-SKL-MID-001 — require(config.middlewarePath) or import(toolArg.extensionPath): attacker writes to config or supplies tool argument to load arbitrary Node.js code with full skill process privileges. Detection: dynamic require/import of non-literal path from config or tool argument → CRITICAL.

SA-SKL-MID-002: Express-style middleware chain allows user headers to set req properties used in auth

Skills that implement an Express-style middleware chain and proxy authentication through an upstream HTTP layer are vulnerable when early middleware sets req properties (like req.userId, req.isAdmin, req.role) from header values and downstream middleware trusts those properties without re-verifying them. If an attacker can send custom headers to the MCP endpoint — either directly or through a misconfigured reverse proxy that forwards arbitrary headers — they set X-User-Id: admin, and the auth middleware sets req.userId = 'admin' from that header. Downstream middleware that checks if (req.userId === 'admin') grantAdminAccess() grants admin access unconditionally. This pattern is endemic in MCP skills that are adapted from API gateway templates that assume they will always sit behind a trusted proxy.

// VULNERABLE: middleware sets req properties from user-controlled headers
function authMiddleware(req, res, next) {
  // Intended to be populated by a trusted upstream auth service
  // But any client can send these headers directly
  req.userId = req.headers['x-user-id'];
  req.isAdmin = req.headers['x-is-admin'] === 'true';
  req.role = req.headers['x-user-role'];
  next();
}

function adminOnlyMiddleware(req, res, next) {
  if (req.isAdmin) {
    next();  // ← trusts req.isAdmin which came from a user header
  } else {
    res.status(403).json({ error: 'admin required' });
  }
}

// Attacker sends: GET /admin/tool HTTP/1.1
//                 X-Is-Admin: true
//                 X-User-Id: admin
// → adminOnlyMiddleware grants access

// MCP adaptation — same vulnerability in tool handlers
server.setRequestHandler('tools/call', async (req) => {
  const role = req.params._meta?.role || req.headers?.['x-user-role'];
  if (role === 'admin') {
    return await performAdminAction();  // ← role from user-controlled source
  }
});

// SECURE: verify the JWT/session token directly — never trust forwarded identity headers
import jwt from 'jsonwebtoken';

function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) { return res.status(401).json({ error: 'missing token' }); }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // Set req properties from the verified token payload, not from headers
    req.userId = decoded.sub;
    req.isAdmin = decoded.role === 'admin';
    req.role = decoded.role;
    next();
  } catch {
    return res.status(401).json({ error: 'invalid token' });
  }
}

// If you must trust upstream proxy headers (e.g. behind AWS ALB or nginx):
// - Restrict access to the MCP port so only the trusted proxy can connect
// - Verify the proxy authentication signature header (e.g. x-amzn-oidc-data)
// - Never expose the MCP port directly to the internet

// DETECTION (SkillAudit):
// req.property = req.headers[...] in middleware where property is used for auth in downstream middleware
const headerToAuthProp = searchAST(
  'req.isAdmin | req.role | req.userId = req.headers[*] in middleware ' +
  'AND downstream middleware reads req.isAdmin | req.role | req.userId for authz decision'
);
if (headerToAuthProp.length > 0) → CRITICAL  // SA-SKL-MID-002

The X-Forwarded-* and X-Real-IP header family has the same problem: skills that derive rate-limit keys, geo-restriction decisions, or logging identifiers from X-Forwarded-For or X-Real-IP trust attacker-supplied values. A client behind a direct connection to the MCP server can forge any X-Forwarded-For value. Use the actual TCP remote address (req.socket.remoteAddress) for security-sensitive decisions, not forwarded headers, unless you verify that only trusted proxies can reach the port.

CRITICAL
SA-SKL-MID-002 — middleware sets req.isAdmin/req.role/req.userId from user-controlled headers; downstream middleware trusts those properties: attacker sends crafted headers to bypass authentication and gain admin access. Detection: req.authProp = req.headers[*] used for authz downstream → CRITICAL.

SA-SKL-MID-003: plugin system with eval-equivalent dynamic code execution

Skills that implement a plugin architecture for operator customisation sometimes use new Function(pluginCode) or eval(pluginCode) to execute plugin code at runtime, reasoning that the plugin code was written by the operator rather than the end user. But if the plugin code is stored in a database or file that can be modified through an admin tool or API, and that admin interface is accessible with operator-level credentials, an attacker who compromises an operator account gains code execution in the skill process. Further, if any plugin loading occurs at tool call time (for per-request plugin selection), the attacker can specify a plugin name whose code they control. new Function() does not sandbox execution — it has the same capabilities as inline code, including access to closures over module-level variables containing credentials.

// VULNERABLE: plugin loaded via new Function() at tool call time
const pluginCache = new Map();

async function loadPlugin(name) {
  if (pluginCache.has(name)) return pluginCache.get(name);
  const pluginRecord = await db.query('SELECT code FROM plugins WHERE name = ?', [name]);
  if (!pluginRecord) throw new Error('plugin not found');

  // Creates a function from stored code — equivalent to eval()
  // Attacker who can write to the plugins table controls code execution
  const fn = new Function('ctx', 'tools', pluginRecord.code);
  pluginCache.set(name, fn);
  return fn;
}

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'run_plugin') {
    const { pluginName, input } = req.params.arguments;
    const plugin = await loadPlugin(pluginName);
    const result = plugin({ input }, server.tools);
    // ^ pluginName from tool argument → plugin code from DB → new Function() execution
    // Attacker supplies a plugin name whose stored code steals process.env
    return { content: [{ type: 'text', text: String(result) }] };
  }
});

// SECURE: plugins as bundled, pre-vetted modules — never eval stored code
const REGISTERED_PLUGINS = new Map([
  ['csv-transform',  (await import('./plugins/csv-transform.js')).default],
  ['json-flatten',  (await import('./plugins/json-flatten.js')).default],
  ['xml-extract',   (await import('./plugins/xml-extract.js')).default],
]);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'run_plugin') {
    const { pluginName, input } = req.params.arguments;

    if (!REGISTERED_PLUGINS.has(pluginName)) {
      throw new Error(`Unknown plugin '${pluginName}'. Available: ${[...REGISTERED_PLUGINS.keys()].join(', ')}`);
    }
    const plugin = REGISTERED_PLUGINS.get(pluginName);
    const result = await plugin(input);
    return { content: [{ type: 'text', text: String(result) }] };
  }
});

// DETECTION (SkillAudit):
// new Function(expr) or eval(expr) where expr derives from database reads,
// config files, or tool arguments
const evalPlugin = searchAST(
  'new Function(expr) | eval(expr) where expr derives from ' +
  'db.query result | config.* | req.params.arguments.*'
);
if (evalPlugin.length > 0) → CRITICAL  // SA-SKL-MID-003

new Function() shares the same globals as inline code: unlike vm.runInNewContext(), which at least attempts a context boundary (one that is trivially bypassed), new Function() executes in the global scope and has implicit access to everything in scope at the call site. A plugin function created with new Function('ctx', pluginCode) can still access process.env, require (if in scope), and any module-level variables, because new Function() creates a function in the global scope, not in an isolated context.

CRITICAL
SA-SKL-MID-003 — new Function(storedCode) or eval(storedCode) for plugin execution: attacker who can write to the plugin store gains code execution; attacker-supplied plugin name chains through DB query to new Function(). Detection: new Function()/eval() with non-literal argument from DB or tool argument → CRITICAL.

SA-SKL-MID-004: middleware logs full request bodies exposing PII and credentials in production

Skills that add request-logging middleware for debugging purposes often log the full request body to help diagnose serialisation issues. In production, tool call bodies contain the arguments the LLM passed to the skill — which may include user-supplied text, document content, API keys the user provided as a tool argument, or personally identifiable information from a data-processing tool call. If the request body is logged at INFO level or above to a centralised logging system (CloudWatch, Datadog, Splunk, Elastic SIEM), the logged data is typically accessible to all engineering staff with log read access, indexed in full-text search, and retained for the configured log retention period. A single PII-containing tool call, replicated across thousands of log lines, constitutes a data protection violation.

// VULNERABLE: full request body logged at INFO level in production
app.use((req, res, next) => {
  console.log('request:', JSON.stringify({
    method: req.method,
    path: req.path,
    body: req.body,  // ← full body: includes tool arguments, PII, API keys
    headers: req.headers,  // ← includes Authorization header with credentials
  }));
  next();
});

// ALSO VULNERABLE: logging in MCP server handler
server.setRequestHandler('tools/call', async (req) => {
  console.log('tool call:', JSON.stringify(req.params));
  // req.params.arguments may contain:
  // - user document text (PII from email, medical records, financial data)
  // - API keys passed as tool arguments ("apiKey": "sk-...")
  // - search queries revealing user intent and personal information
  // All written to production logs accessible to all staff with log read access
});

// SECURE: log only metadata — never log argument values
server.setRequestHandler('tools/call', async (req) => {
  const sanitizedMeta = {
    toolName: req.params.name,
    argumentKeys: Object.keys(req.params.arguments ?? {}),  // keys only, not values
    argCount: Object.keys(req.params.arguments ?? {}).length,
    requestId: req.params._meta?.requestId,
    timestamp: new Date().toISOString(),
  };
  logger.info('tool_call_received', sanitizedMeta);
  // Never: logger.info('tool_call_received', { ...sanitizedMeta, args: req.params.arguments })
});

// For debugging credential fields specifically:
function redactCredentials(obj, depth = 0) {
  if (depth > 5 || typeof obj !== 'object' || obj === null) return obj;
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => {
      const sensitive = /key|token|secret|password|credential|auth/i.test(k);
      return [k, sensitive ? '[REDACTED]' : redactCredentials(v, depth + 1)];
    })
  );
}

// DETECTION (SkillAudit):
// console.log / logger.* calls inside tool handlers or middleware where the argument
// includes req.params.arguments, req.body, or req.headers.authorization
const bodyLogging = searchAST(
  'console.log | logger.*(expr) where expr includes ' +
  'req.params.arguments | req.body | req.headers.authorization'
);
if (bodyLogging.length > 0) → HIGH  // SA-SKL-MID-004

Log retention extends the breach window: if tool arguments containing PII or credentials are logged to a centralised system with a 90-day retention period, a single vulnerable skill version deployed for a week creates a 90-day window where that data is accessible to anyone with log read access — even after the skill is patched and redeployed. Redact argument values at the logging call site, not after the fact via a log scrubbing pipeline, which may miss some log destinations.

HIGH
SA-SKL-MID-004 — middleware or handler logs req.body or req.params.arguments verbatim in production: user PII, document content, and API keys written to SIEM-indexed logs accessible to all staff; log retention extends exposure window. Detection: console.log/logger.* with req.params.arguments or req.body → HIGH.

SA-SKL-MID summary

IDPatternSeverityImpact
SA-SKL-MID-001require(config.middlewarePath) or import(toolArg)CRITICALArbitrary code execution with full skill process privileges at startup or hot-reload
SA-SKL-MID-002Auth properties set from user-controlled HTTP headersCRITICALAttacker bypasses authentication by sending crafted headers; gains admin access
SA-SKL-MID-003new Function(storedCode) or eval(storedCode) for pluginsCRITICALCode execution from DB or tool argument; attacker controls plugin store gains process privilege
SA-SKL-MID-004Full request body logged at INFO level in productionHIGHPII, document content, and credentials written to centralised logs accessible to all staff

How SkillAudit detects SA-SKL-MID patterns

SkillAudit traces all require() and import() calls with non-literal paths, flagging those where the path expression can be influenced by config file fields, environment variables, or tool arguments (SA-SKL-MID-001). It identifies middleware functions that assign HTTP header values to req properties used for authentication decisions in downstream middleware (SA-SKL-MID-002). It finds new Function() and eval() calls where the code argument derives from database query results, config values, or tool arguments (SA-SKL-MID-003). And it checks all logging call sites within tool handlers and middleware for argument expressions that include req.params.arguments, req.body, req.headers.authorization, or any field with a credential-pattern key name (SA-SKL-MID-004).

Run a free SkillAudit scan to check your Claude skill for all four SA-SKL-MID patterns alongside SA-SKL-SBX sandbox escape, SA-SKL-DEP dependency confusion, and SA-SKL-LOG observability exposure.