Skip to content

security - please fix #1

@kxddry

Description

@kxddry

Security: unauthenticated channel HTTP endpoint enables DNS-rebinding prompt injection into Claude Code sessions

Summary

plugin/channel/server.js listens on 127.0.0.1:47123-47132 with no authentication, no Host header check, and no Origin check. Combined with a world-readable registry at /tmp/code-review-sessions.json, this enables two practical attack paths against Claude Code sessions that have the channel loaded:

  1. DNS rebinding from any browser tab the user has open. A malicious page can POST /review to localhost via a 1-second-TTL DNS flip; same-origin policy treats the rebound request as same-origin, so the body is delivered. The plugin's own server-side instructions then direct Claude to "Address each comment: fix the issue…" — a confused-deputy / indirect prompt-injection vector against a session that typically has Read/Edit/Write/Bash authorized.
  2. Co-located local UIDs. The session registry leaks pid, cwd, and port at mode 0644, giving any local user the data needed to deliver payloads to another user's session on shared/CI hosts.

Severity: High for users running with auto-approval / bypassPermissions: true; Medium otherwise (model safety training is the only remaining layer, and frontier-model jailbreaks against well-crafted prompts are not zero).

Affected code (master @ HEAD)

  • plugin/channel/server.js:62-86/review POST handler accepts any body, forwards to MCP unchanged
  • plugin/channel/server.js:23-27 — registry write at default mode (0644) in shared /tmp
  • plugin/channel/server.js:114-119 — server-side instructions that tell Claude to act on channel content

Threat model

The HTTP listener has none of:

  • bearer token / shared secret
  • Host header validation (would defeat DNS rebinding)
  • Origin / Sec-Fetch-Site checks
  • per-session capability

The body of POST /review is taken verbatim and delivered to Claude as <channel source="code-review">…</channel>, which the plugin's own instructions tell the model to action. The attacker controls model input that the model is instructed to obey, in a context where the model has tool access. This is a classic confused-deputy.

PoC — DNS rebinding from a browser tab

Attacker DNS: rebind.example answers attacker_ip (TTL=1) on the first query and 127.0.0.1 on the second.

<script>
  // 1) prime same-origin cache against attacker_ip
  fetch('http://rebind.example/init').then(() =>
    // 2) ~2s later, browser re-resolves; now points at 127.0.0.1
    setTimeout(() =>
      fetch('http://rebind.example:47123/review', {
        method: 'POST',
        body: '<malicious payload here>'
      }), 2000));
</script>

The 10-port range is trivially enumerated (try ports 47123–47132 in parallel). No CORS escape needed — the request is a simple POST and the attacker doesn't need to read the response.

The body lands here unchanged:

// plugin/channel/server.js (~L62-86)
if (req.method === 'POST' && req.url === '/review') {
  const body = await collectBody(req);
  await mcp.notification({
    method: 'notifications/claude/channel',
    params: { content: body },
  });
}

Suggested fixes

1. Reject requests whose Host header isn't a loopback literal

This alone kills DNS rebinding — the rebound request still carries the attacker's hostname in Host.

const expectedHosts = new Set([
  `127.0.0.1:${port}`,
  `localhost:${port}`,
  `[::1]:${port}`,
]);
if (!expectedHosts.has(req.headers.host ?? '')) {
  res.writeHead(403); res.end('forbidden'); return;
}

2. Require a per-session bearer token

Generate at startup, include in the registry entry, require on every request:

import crypto from 'node:crypto';
const sessionToken = crypto.randomBytes(32).toString('base64url');

// in registry entry: { pid, cwd, port, name, token: sessionToken, startedAt }

// on every request:
const auth = req.headers.authorization ?? '';
if (auth !== `Bearer ${sessionToken}`) {
  res.writeHead(401); res.end('unauthorized'); return;
}

VS Code reads the token from the registry along with the port. Co-located UIDs can no longer post to other users' sessions even if they enumerate ports.

3. Write the session registry with mode 0600 and per-user path

import os from 'node:os';
const registryPath = path.join(
  os.tmpdir(),
  `code-review-sessions-${os.userInfo().uid}.json`
);

const fd = fs.openSync(tmpPath, 'wx', 0o600);
fs.writeFileSync(fd, JSON.stringify(entries));
fs.closeSync(fd);
fs.renameSync(tmpPath, registryPath);

This prevents the cwd/PID/port leak on shared hosts. Pair with #2 so even an unmask-mistake-class bug doesn't fall back to "no auth at all."

4. (Defense in depth) Sanitize channel body before forwarding

The channel content is forwarded verbatim and the model is instructed to obey it. Consider stripping or HTML-escaping sequences that mimic system tags (<system-reminder>, </channel>, opening/closing channel tags) before delivery to the MCP notification — these are the most effective prompt-injection scaffolds against current frontier models.

5. (Defense in depth) Replace PID-based liveness with a registered-token handshake

process.kill(pid, 0) is TOCTOU and PID-reuse-vulnerable on long-running hosts. Combined with the lack of token, a recycled PID could direct a review to the wrong process. With #2 in place, this becomes a non-issue (mismatched token → rejected) but worth documenting.

Why this matters

A Claude Code session loaded with --dangerously-load-development-channels typically has at minimum Read/Edit authorized, often Bash, and many users run with bypassPermissions: true or wildcard tool allowlists for ergonomics. In that environment, a single accepted /review POST can attempt arbitrary tool use mediated by the model. Model safety training is a real layer of defense — it catches naive prompt injection — but it is not architectural defense, and the cost of adding architectural defense here is small (≈30 lines).

Disclosure

Filing here because the fixes are minor and a public issue accelerates a fix. Happy to send a PR for #1, #2, and #3 if that helps — let me know.

Environment used for audit

  • Repo: etsd-tech/vscode-agent-annotator @ master
  • Files reviewed: plugin/channel/server.js, plugin/.mcp.json, plugin/hooks/hooks.json, extension/src/{claudeInstaller,sessionRegistry,reviewSubmitter}.ts, both package.jsons
  • No telemetry, no child_process/exec, no eval, no postinstall scripts found — those are clean.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions