DEV Community

Cover image for 10 Security Lessons from Building a Windows MCP Server
Incognitum
Incognitum

Posted on Fully Autonomous

10 Security Lessons from Building a Windows MCP Server

Why I Built This

I use Claude, Cursor, and ChatGPT daily. I wanted them to control my Windows machine — run terminal commands, read files, search code, spawn background tasks.

The problem? Most MCP servers for Windows were either:

  • Cross-platform (not optimized for Windows quirks)
  • Lacking security (no fail-closed defaults)
  • Heavy (loaded every tool into context, wasting tokens)

So I built my own. Here's what I learned.


Lesson 1: PowerShell Has 4 Unicode Smart Quotes That Will Break Your Escapes

I thought escaping ' and " was enough. I was wrong.

Windows users paste text from Word, Outlook, and Teams. Those applications use smart quotes:

const SMART_QUOTES = {
  '\u2018': "'", // left single quote
  '\u2019': "'", // right single quote
  '\u201A': "'", // low-9 single quote
  '\u201B': "'", // high-reversed-9 single quote
};
Enter fullscreen mode Exit fullscreen mode

If you don't escape these, an attacker can craft a command that bypasses your validation.

The Fix: Escape them all, plus reject NUL bytes and unescaped newlines. Better yet — pass arguments via environment variables, not string interpolation:

# Bad
powershell.exe -Command "Write-Output '${userInput}'"

# Good
$env:WH_ARG_0 = "user input here"
powershell.exe -Command "Write-Output $env:WH_ARG_0"
Enter fullscreen mode Exit fullscreen mode

Lesson 2: Your API Keys Are Leaking to Subprocesses Right Now

This one hurt.

When you spawn() a subprocess in Node.js, it inherits the entire parent process environment — including process.env. If you have OPENAI_API_KEY, ANTHROPIC_API_KEY, or any sensitive token, your child processes can read them.

import { spawn } from 'node:child_process';

// This inherits EVERYTHING from process.env
spawn('some-command', [], { shell: true });
Enter fullscreen mode Exit fullscreen mode

The Fix: Scrub environment variables before spawning:

function getCleanChildEnv(): NodeJS.ProcessEnv {
  const clean: NodeJS.ProcessEnv = {};
  const BLOCKED = /(_TOKEN|_SECRET|_KEY|_PASSWORD|AUTH)/i;

  for (const [key, value] of Object.entries(process.env)) {
    if (!BLOCKED.test(key)) {
      clean[key] = value;
    }
  }
  return clean;
}

spawn('some-command', [], {
  shell: true,
  env: getCleanChildEnv(),
});
Enter fullscreen mode Exit fullscreen mode

Why this matters: CVE-2026-40159 affected a popular MCP gateway that leaked API keys to every subprocess it spawned.


Lesson 3: Path Traversal Has Unicode Bypasses

You think ../ is the only path traversal pattern? Check this:

// This bypasses most path validation
const malicious = "C:\Users\admin"; // Full-width Unicode
Enter fullscreen mode Exit fullscreen mode

Windows will resolve full-width characters to their ASCII equivalents — but your regex won't catch them.

The Fix: Normalize Unicode before validation:

import path from 'node:path';

function isPathAllowed(targetPath: string, allowedDirs: string[]): boolean {
  // NFKC normalization converts full-width to ASCII
  const normalized = targetPath.normalize('NFKC');
  const resolved = path.resolve(normalized);

  return allowedDirs.some(dir => 
    resolved.startsWith(path.resolve(dir))
  );
}
Enter fullscreen mode Exit fullscreen mode

Lesson 4: Sessions Should Have Both Sliding AND Absolute Expiration

I initially used only sliding expiration (15 minutes). But this has a flaw: sessions can live forever if the user keeps using them.

A compromised session token could remain valid for months.

The Fix: Add an absolute lifetime cap:

class SessionManager {
  private ttlMs = 15 * 60 * 1000;      // 15 min sliding
  private absoluteTtlMs = 12 * 60 * 60 * 1000; // 12 hour absolute

  validate(id: string): boolean {
    const session = this.sessions.get(id);
    if (!session) return false;

    const now = Date.now();

    // Absolute check — cannot be extended
    if (now - session.createdAt > this.absoluteTtlMs) {
      this.sessions.delete(id);
      return false;
    }

    // Sliding check
    if (now - session.lastAccessedAt > this.ttlMs) {
      this.sessions.delete(id);
      return false;
    }

    session.lastAccessedAt = now;
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Lesson 5: One-Time Exchange Tokens Beat Long-Lived Query Tokens

Before: Users passed their master token as ?token=xxx in the URL.

Problem:

  • Leaks into browser history
  • Leaks into reverse proxy logs
  • Leaks into referrer headers
  • Can't be revoked per-session

The Fix: One-time exchange tokens that are invalidated after first use:

app.post('/auth/exchange', (req, res) => {
  const { token } = req.body;

  if (!safeCompare(token, currentExchangeToken)) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Invalidate immediately
  currentExchangeToken = null;

  // Issue ephemeral session cookie
  const sessionId = sessionManager.createSession({ ip: req.ip });
  res.setHeader('Set-Cookie', 
    `winhelm_session=${sessionId}; HttpOnly; SameSite=Strict; Max-Age=900`
  );
  res.json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

Lesson 6: origin: "*" + credentials: true Doesn't Work

This one is subtle. According to the CORS spec, browsers will reject responses that have both:

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Credentials: true

So if you naively set these two, cookies won't work.

The Fix: Use dynamic origin reflection:

app.use(cors({
  origin: (origin, callback) => {
    // Reflect the origin back instead of using "*"
    callback(null, true);
  },
  credentials: true,
}));
Enter fullscreen mode Exit fullscreen mode

But only do this if you also validate the origin — otherwise you're opening yourself up to CSRF.


Lesson 7: null and undefined Are Not the Same in TypeScript

I had this bug for weeks and didn't notice:

// Bad
const authToken = options.authToken ?? config.authToken;

// The `??` operator treats null AND undefined as "use fallback"
// So passing `{ authToken: null }` would still pull from config
Enter fullscreen mode Exit fullscreen mode

This broke my integration tests — they couldn't test the "no auth" scenario because config always had a token.

The Fix:

const authToken = options.authToken !== undefined 
  ? options.authToken 
  : config.authToken;
Enter fullscreen mode Exit fullscreen mode

Now null means "explicitly no auth" and undefined means "use config default."


Lesson 8: Fail-Closed by Default Is Not Enough — You Need Startup Gates

I thought "fail-closed" meant: empty allowedDirectories: [] blocks all filesystem access.

But what about network exposure? If someone runs:

node server.js --host 0.0.0.0
Enter fullscreen mode Exit fullscreen mode

...without an auth token, my server would happily bind to all interfaces.

The Fix: Refuse to start:

if (!isLoopback(options.host) && !authToken) {
  console.error('FATAL: Cannot bind to non-loopback without auth token');
  console.error('   Set --auth <token> or use --host 127.0.0.1');
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Fail-closed should mean fail-LOUDLY-closed.


Lesson 9: Audit Logs Need SHA-256 Hashed Session IDs

I logged session IDs in plaintext for correlation. Then I realized: if the log leaks, so does every session.

The Fix: Hash the session ID before logging:

import { createHash } from 'node:crypto';

function hashSessionId(sessionId: string): string {
  return createHash('sha256')
    .update(sessionId)
    .digest('hex')
    .slice(0, 12); // Enough for correlation, useless for hijacking
}
Enter fullscreen mode Exit fullscreen mode

You can still correlate requests within a session — but you can't use the log to hijack it.


Lesson 10: Buffer Caps and Process Tree Cleanup Matter

Long-running MCP servers have two failure modes:

  1. Memory blowup — buffering stdout/stderr from runaway processes
  2. Zombie processes — orphaned child processes that never die

The Fix:

// Cap buffers
const MAX_BUFFER = 1024 * 1024; // 1MB
let stdoutSize = 0;

child.stdout.on('data', (chunk) => {
  stdoutSize += chunk.length;
  if (stdoutSize > MAX_BUFFER) {
    child.kill('SIGTERM');
    reject(new Error('Output exceeded 1MB limit'));
  }
  // ... process chunk
});
Enter fullscreen mode Exit fullscreen mode

For process cleanup on Windows:

// Kills the entire process tree, not just the parent
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
Enter fullscreen mode Exit fullscreen mode

On Linux/macOS, use process groups instead.


The Results

After applying all 10 lessons:

  • Tests: 154 → 219 (+65 security regression tests)
  • npm audit: 0 vulnerabilities
  • Attack surface: Reduced significantly
  • Confidence: Much higher

You can see the full implementation: github.com/dhammawatthumpra-coder/winhelm-mcp


What I'd Do Differently Next Time

  1. Start with threat modeling — I added THREAT_MODEL.md too late
  2. Write security tests first — TDD for security is underrated
  3. Get independent review early — Fresh eyes catch what you miss
  4. Document accepted risks — Not every alert needs fixing

Final Thought

Building an MCP server is easy. Building a secure MCP server is a different game.

If you're building one, remember:

  • Fail closed, fail loudly, fail often
  • Assume every input is malicious
  • Document what you don't protect against
  • Test your security boundaries

No MCP server is 100% secure — but you can make the attacker's job much harder.


Have you built an MCP server? What security lessons did you learn? Share in the comments — I'm still learning too.

If you found this useful, the repo is here. Stars are appreciated but not required.


Top comments (0)