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:
- 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.
- 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.
Security: unauthenticated channel HTTP endpoint enables DNS-rebinding prompt injection into Claude Code sessions
Summary
plugin/channel/server.jslistens on127.0.0.1:47123-47132with no authentication, noHostheader check, and noOrigincheck. 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:POST /reviewto 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.pid,cwd, and port at mode0644, 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—/reviewPOST handler accepts any body, forwards to MCP unchangedplugin/channel/server.js:23-27— registry write at default mode (0644) in shared/tmpplugin/channel/server.js:114-119— server-side instructions that tell Claude to act on channel contentThreat model
The HTTP listener has none of:
Hostheader validation (would defeat DNS rebinding)Origin/Sec-Fetch-SitechecksThe body of
POST /reviewis 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.exampleanswersattacker_ip(TTL=1) on the first query and127.0.0.1on the second.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:
Suggested fixes
1. Reject requests whose
Hostheader isn't a loopback literalThis alone kills DNS rebinding — the rebound request still carries the attacker's hostname in
Host.2. Require a per-session bearer token
Generate at startup, include in the registry entry, require on every request:
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
0600and per-user pathThis 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-channelstypically has at minimum Read/Edit authorized, often Bash, and many users run withbypassPermissions: trueor wildcard tool allowlists for ergonomics. In that environment, a single accepted/reviewPOST 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
etsd-tech/vscode-agent-annotator@ masterplugin/channel/server.js,plugin/.mcp.json,plugin/hooks/hooks.json,extension/src/{claudeInstaller,sessionRegistry,reviewSubmitter}.ts, bothpackage.jsonschild_process/exec, noeval, no postinstall scripts found — those are clean.