← Blog

Claude skill EventEmitter and stream security

Claude skills that use Node.js EventEmitter for pub/sub coordination and streams for data processing face four attack patterns in the SA-SKL-EVT family: user-controlled event names that hijack wildcard listeners, pipe() calls that leave sockets and file handles open on error, EventEmitter listener accumulation that exhausts memory, and process.on('uncaughtException') handlers that silently swallow errors and mask security failures throughout the skill.

By SkillAudit  |  2 Nov 2026

EventEmitter is the backbone of asynchronous coordination in Node.js: HTTP servers, streams, WebSocket libraries, and custom pub/sub systems all inherit from it. MCP skills commonly use EventEmitter to signal completion of background operations, stream large responses incrementally, or fan out tool results to multiple consumers. When event names or stream configurations are influenced by user-supplied tool arguments, four vulnerability classes emerge. Two produce resource exhaustion denial-of-service; one enables silent security failures that hide authentication bypasses and data leaks from monitoring systems; and one enables listener hijacking that intercepts event payloads across tool calls.

SA-SKL-EVT-001: user-controlled event names enable wildcard listener hijacking

Skills that allow users to specify an event name — for instance, the channel to subscribe to in a pub/sub tool, or the hook name in a plugin system — and then emit on that name are vulnerable to wildcard listener hijacking. Libraries like eventemitter2 and eventemitter3 support wildcard listeners (* matches any single segment, ** matches all segments). An existing wildcard listener registered for operational monitoring or logging will fire on every event the attacker emits, receiving the event payload intended for a specific channel. If the payload contains per-user data, the monitoring listener processes another user’s data. Conversely, if the attacker registers their own wildcard listener (possible if the skill exposes a listener-registration tool), all events across all channels are intercepted.

// VULNERABLE: skill emits on user-controlled event name (eventemitter2 with wildcards)
import EventEmitter2 from 'eventemitter2';

const bus = new EventEmitter2({ wildcard: true, delimiter: '.' });

// Monitoring listener registered internally — receives all events under 'session.*'
bus.on('session.*', (payload) => {
  auditLog.record(payload);  // ← will receive payloads for event name attacker chooses
});

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'publish_event') {
    const { channel, data } = req.params.arguments;
    // channel intended to be 'session.123' (user's own session ID)
    // Attacker supplies: channel = 'session.*' — fires the wildcard monitoring listener
    // Attacker can also supply: channel = 'session.other-user-id' to target another user

    bus.emit(channel, { userId: currentUserId, data });
    // ^ attacker controls which listeners receive the payload
    return { content: [{ type: 'text', text: 'published' }] };
  }
});

// SECURE: validate channel against allowlist or normalise to a namespaced pattern
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'publish_event') {
    const { channel, data } = req.params.arguments;

    // Reject wildcards and delimiters in user-supplied channel names
    if (!/^[a-zA-Z0-9_-]+$/.test(channel)) {
      throw new Error('channel must be alphanumeric — wildcards and dots not allowed');
    }
    // Scope to authenticated user's namespace — attacker cannot escape to other namespaces
    const scopedChannel = `user.${currentUserId}.${channel}`;

    bus.emit(scopedChannel, { data });
    return { content: [{ type: 'text', text: 'published' }] };
  }
});

// DETECTION (SkillAudit):
// emitter.emit(variableEventName) where variableEventName derives from tool arguments
// and the emitter has wildcard: true or uses eventemitter2/3
const wildcardEmit = searchAST(
  'emitter.emit(expr, ...) where expr includes req.params.arguments.* ' +
  'AND emitter is EventEmitter2/3 or has wildcard option'
);
if (wildcardEmit.length > 0) → HIGH  // SA-SKL-EVT-001

Standard Node.js EventEmitter does not support wildcards, but the pattern still matters: even without wildcards, emitting on a user-controlled event name can trigger internal listeners the attacker did not expect — 'error', 'newListener', 'removeListener', or any named listener that happens to match. Supplying 'error' as the event name with a crafted payload that looks like an Error object can crash the process if no error handler is attached. Always scope emitted event names to a user-specific namespace.

HIGH
SA-SKL-EVT-001 — emitter.emit(userControlledName, ...): attacker supplies a wildcard event name that fires monitoring/logging listeners with another user’s payload, or emits on internal event names ('error') to crash the host. Detection: emit() with req.params.arguments.* event name → HIGH.

SA-SKL-EVT-002: readable stream piped to writable stream without destroy() on error

Node.js stream.pipe(dest) is the idiomatic way to move data from a readable source (an HTTP response body, a file read stream, a decompression transform) to a writable destination (a file write stream, an HTTP response, a database insert stream). pipe() attaches 'data' and 'end' listeners on the readable and calls dest.end() when the readable finishes. However, pipe() does not automatically call dest.destroy() when either stream emits an 'error' event. If a skill pipes a user-supplied URL response to a file write stream and the HTTP request fails mid-stream, the writable file stream remains open indefinitely. Over repeated failed requests — which the attacker triggers by supplying URLs that return partial responses — file descriptors accumulate until the OS file descriptor limit is reached. At that point, all file I/O in the process fails, including logging and any writes the MCP host infrastructure performs.

// VULNERABLE: pipe without error cleanup
import fs from 'fs';
import https from 'https';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'download_file') {
    const { url, outputPath } = req.params.arguments;

    return new Promise((resolve, reject) => {
      const writeStream = fs.createWriteStream(outputPath);
      https.get(url, (response) => {
        response.pipe(writeStream);
        // MISSING: error handlers that call writeStream.destroy()
        // If response errors mid-stream: writeStream stays open (fd leak)
        // If writeStream errors: response stays open (socket leak)

        writeStream.on('finish', () => resolve({ content: [{ type: 'text', text: 'done' }] }));
      }).on('error', reject);
      // ^ .on('error') on the request, but writeStream is still not destroyed
    });
  }
});

// SECURE: use stream.pipeline() which handles cleanup automatically
import { pipeline } from 'stream/promises';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'download_file') {
    const { url, outputPath } = req.params.arguments;

    await new Promise((resolve, reject) => {
      https.get(url, async (response) => {
        try {
          // pipeline() calls destroy() on all streams if any emits 'error'
          await pipeline(response, fs.createWriteStream(outputPath));
          resolve({ content: [{ type: 'text', text: 'done' }] });
        } catch (err) {
          reject(err);
        }
      }).on('error', reject);
    });
  }
});

// ALSO SECURE: manual destroy on both streams
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'download_file') {
    const { url, outputPath } = req.params.arguments;

    return new Promise((resolve, reject) => {
      const writeStream = fs.createWriteStream(outputPath);
      https.get(url, (response) => {
        response.pipe(writeStream);

        response.on('error', (err) => { writeStream.destroy(err); reject(err); });
        writeStream.on('error', (err) => { response.destroy(err); reject(err); });
        writeStream.on('finish', resolve);
      }).on('error', (err) => { writeStream.destroy(); reject(err); });
    });
  }
});

// DETECTION (SkillAudit):
// readable.pipe(writable) where neither stream has an 'error' listener calling destroy()
const pipeNoCleanup = searchAST(
  'readable.pipe(writable) where writable has no .on("error", fn) call ' +
  'that invokes readable.destroy() OR stream.pipeline() is not used'
);
if (pipeNoCleanup.length > 0) → HIGH  // SA-SKL-EVT-002

Prefer stream.pipeline() over pipe() in all new skill code: stream.pipeline(...streams, callback) (or the promise version from stream/promises) automatically calls destroy() on all streams in the pipeline if any of them emit an 'error' event or the callback receives an error. This is the correct default for production Node.js code. Reserve pipe() for cases where you need fine-grained control, and always attach explicit bidirectional 'error' listeners when you do.

HIGH
SA-SKL-EVT-002 — readable.pipe(writable) without error-path destroy(): mid-stream HTTP or write errors leave file descriptors and sockets open; repeated attacker-triggered failures exhaust OS fd limit, disabling all I/O in the MCP host. Detection: pipe() without bidirectional error + destroy handlers → HIGH.

SA-SKL-EVT-003: EventEmitter listener accumulation causing memory exhaustion

Node.js EventEmitter warns when more than 10 listeners are attached to a single event (configurable via setMaxListeners()). The warning is a heuristic for detecting listener leaks — the common case is a loop that adds listeners without removing them, so the count grows without bound. In MCP skills, the leak pattern is a tool handler that calls emitter.on('event', listener) inside the request handler on each tool invocation but never calls emitter.removeListener() or emitter.off(). After the LLM makes 11 tool calls, Node.js prints the MaxListenersExceededWarning to stderr. After thousands of calls — which a looping LLM or a rate-limit-free session produces — the listener array exhausts heap memory. The MaxListenersExceededWarning also leaks the internal structure of the emitter to stderr, which is visible in MCP host logs.

// VULNERABLE: listener added on each tool call, never removed
import EventEmitter from 'events';

const dataEmitter = new EventEmitter();

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

    // Each tool invocation adds a new listener — listeners accumulate
    dataEmitter.on('data', (update) => {
      if (update.topic === topic) {
        // process update for this subscriber
      }
    });
    // MISSING: removeListener when subscription ends
    // After 11 tool calls: MaxListenersExceededWarning
    // After 100k tool calls: heap OOM, process crash
    return { content: [{ type: 'text', text: 'subscribed' }] };
  }
});

// ALSO VULNERABLE: using once() in a loop still accumulates if the event never fires
for (const item of items) {
  emitter.once('complete', () => processItem(item));
  // If 'complete' never fires for some items, their once-listeners never clean up
}

// SECURE: use once() with a cleanup path, or track and remove listeners explicitly
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'subscribe_to_updates') {
    const { topic } = req.params.arguments;

    return new Promise((resolve) => {
      const listener = (update) => {
        if (update.topic === topic) {
          dataEmitter.removeListener('data', listener);  // ← remove after first match
          resolve({ content: [{ type: 'text', text: JSON.stringify(update) }] });
        }
      };

      dataEmitter.on('data', listener);

      // Safety: remove listener after timeout to prevent accumulation if event never fires
      setTimeout(() => {
        dataEmitter.removeListener('data', listener);
        resolve({ content: [{ type: 'text', text: 'timeout: no updates' }] });
      }, 10_000);
    });
  }
});

// DETECTION (SkillAudit):
// emitter.on() calls inside request handler scope without a paired removeListener/off()
const listenerLeak = searchAST(
  'emitter.on(event, listener) inside tool handler function ' +
  'where listener is not removed via removeListener/off() or once() in the same scope'
);
if (listenerLeak.length > 0) → MEDIUM  // SA-SKL-EVT-003

MaxListenersExceededWarning is a canary, not the actual problem: the warning fires at 11 listeners as an early detection signal. The actual memory exhaustion occurs at hundreds of thousands of listeners in a process that has been running without restarts. An LLM in a long agentic session — or a benchmark script that calls the skill in a loop — can accumulate this many listeners in minutes. Set emitter.setMaxListeners(20) only if you have a legitimate reason to attach more than 10 listeners, not to silence the warning.

MEDIUM
SA-SKL-EVT-003 — emitter.on() inside tool handler without paired removeListener(): listeners accumulate across tool calls; LLM in agentic session exhausts heap memory; MaxListenersExceededWarning leaks emitter internals to logs. Detection: emitter.on() in handler scope without cleanup → MEDIUM.

SA-SKL-EVT-004: process.on('uncaughtException') swallowing errors and masking security failures

Skills that add a global process.on('uncaughtException', handler) handler to prevent crashes from unhandled errors silently swallow exceptions that should surface as tool errors, MCP host alerts, or monitoring signals. When an authentication check throws an unexpected exception (for instance, because the JWT library receives a malformed token crafted by an attacker), the uncaughtException handler absorbs it and logs a generic message. The tool call returns without completing its intended work, but no error is returned to the LLM and no alert fires. The attacker can use this to suppress error signals: by crafting inputs that trigger specific exceptions in authentication or authorisation paths, they cause those checks to silently fail while the skill appears to complete successfully. process.on('unhandledRejection') has the same problem for Promise-based skill code.

// VULNERABLE: broad uncaughtException handler that silences all errors
process.on('uncaughtException', (err) => {
  console.error('uncaughtException suppressed:', err.message);
  // Swallows the exception — execution continues
  // If the exception was thrown mid-auth-check, the check is bypassed silently
  // If the exception carries sensitive data, err.message goes to logs unredacted
});

process.on('unhandledRejection', (reason) => {
  console.error('unhandledRejection suppressed:', reason);
  // Same problem for async code — a rejected auth Promise is silently discarded
});

// A poorly-implemented auth middleware throws on malformed JWT:
async function verifyToken(token) {
  const decoded = jwt.verify(token, SECRET);  // throws on malformed token
  return decoded.userId;
}

server.setRequestHandler('tools/call', async (req) => {
  const userId = await verifyToken(req.params._meta?.token);  // may throw
  // If verifyToken throws, the unhandledRejection handler catches it
  // The rest of the handler doesn't run — but the skill also doesn't return an error
  // LLM sees: tool call timed out or returned empty. Auth bypass is invisible.
  const data = await getPrivateData(userId);
  return { content: [{ type: 'text', text: data }] };
});

// SECURE: handle errors at the point of failure; use uncaughtException only as a last resort
server.setRequestHandler('tools/call', async (req) => {
  try {
    const userId = await verifyToken(req.params._meta?.token);
    const data = await getPrivateData(userId);
    return { content: [{ type: 'text', text: data }] };
  } catch (err) {
    // Return explicit error to LLM — don't swallow it
    return {
      isError: true,
      content: [{ type: 'text', text: 'Authentication failed. Please re-authenticate.' }]
    };
  }
});

// If you must use uncaughtException as a safety net (e.g. for unexpected third-party throws),
// always re-throw after logging to let the process exit cleanly:
process.on('uncaughtException', (err) => {
  console.error('FATAL uncaughtException:', err);
  process.exit(1);  // ← exit rather than continue in unknown state
});

// DETECTION (SkillAudit):
// process.on('uncaughtException', handler) where handler does NOT call process.exit()
const swallowingHandler = searchAST(
  'process.on("uncaughtException" | "unhandledRejection", fn) ' +
  'where fn body does not contain process.exit()'
);
if (swallowingHandler.length > 0) → HIGH  // SA-SKL-EVT-004

Swallowing uncaughtException leaves the process in an unknown state: the Node.js documentation explicitly warns that continuing after an uncaught exception is dangerous because the process may be in an inconsistent state — partially written data structures, open transactions, locks held. In an MCP skill, this means the next tool call may operate on corrupted state from the previous exception. The only safe response to a genuinely unexpected uncaught exception is to log it and call process.exit(1). Catch expected errors with try/catch at the call site instead.

HIGH
SA-SKL-EVT-004 — process.on('uncaughtException') without process.exit(): auth exceptions are silently swallowed; attacker-crafted malformed inputs bypass security checks without triggering tool errors or monitoring alerts. Detection: uncaughtException/unhandledRejection handler without process.exit() → HIGH.

SA-SKL-EVT summary

IDPatternSeverityImpact
SA-SKL-EVT-001User-controlled event name in emitter.emit()HIGHWildcard listener hijacking; monitoring listeners receive another user’s payload
SA-SKL-EVT-002pipe() without error-path destroy()HIGHfd and socket leaks on error; repeated attacker failures exhaust OS fd limit
SA-SKL-EVT-003emitter.on() in handler without cleanupMEDIUMListener accumulation; heap OOM in long LLM sessions; internals leak via MaxListenersExceededWarning
SA-SKL-EVT-004uncaughtException handler without process.exit()HIGHAuth exceptions swallowed silently; attacker crafts inputs to suppress security signals

How SkillAudit detects SA-SKL-EVT patterns

SkillAudit’s analysis identifies EventEmitter and stream usage across the skill codebase. For SA-SKL-EVT-001, it traces emitter.emit() calls where the event name expression includes tool argument variables and flags cases using EventEmitter2/3 with wildcard mode. For SA-SKL-EVT-002, it locates pipe() calls and verifies that both the readable and writable streams have 'error' listeners that call destroy(), or that stream.pipeline() is used instead. SA-SKL-EVT-003 is detected by finding emitter.on() calls within tool handler function bodies without paired removeListener() or once() calls. SA-SKL-EVT-004 is a global check for process.on('uncaughtException') and process.on('unhandledRejection') handlers whose bodies do not include process.exit().

Run a free SkillAudit scan to check your Claude skill for all four SA-SKL-EVT patterns alongside SA-SKL-RCL resource cleanup leaks, SA-SKL-WRK Worker Thread vulnerabilities, and SA-SKL-LOG logging exposure.