Skip to content

Commit 206113d

Browse files
ndemiancclaude
andcommitted
fix(mcp): harden the untrusted env copy; make the collision test actually collide
Second round of PR #29 review (Copilot). Both were real. 1. normalizeServer copied a repo-authored env with Object.assign, which hands an own "__proto__" key (JSON.parse creates one) to the prototype SETTER instead of copying it. Measured before changing anything: this is NOT currently exploitable — the object-valued payload is rejected by the existing "env must be an object of strings" check, and a string-valued __proto__ is ignored by the setter. But that makes the safety INCIDENTAL: it holds only as long as nobody relaxes the value check. Now structural — safeEnvCopy skips __proto__/constructor/prototype and copies into a fresh object. (Kept a normal object rather than Object.create(null): later code may reasonably call hasOwnProperty on it.) It also stops an env var named __proto__ from silently vanishing into the setter. 2. The "servers that sanitize alike" test paired 'my-server' with 'my/server', but '-' is already legal so they never collided — the test passed without ever entering the dedupe path, i.e. it proved nothing. Now pairs 'my/server' with 'my:server' (both sanitize to 'my_server'), asserts the collision as an explicit PRECONDITION so it cannot rot back, and asserts the dedupe reported it. Added a prototype-pollution regression test alongside. Verified: 24 tests pass; both fixes mutation-checked — restoring Object.assign fails the suite (exit 1, caught via the `constructor` key), and restoring the non-colliding inputs also fails (exit 1, caught by the new precondition). Full CI gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c0412e commit 206113d

2 files changed

Lines changed: 51 additions & 6 deletions

File tree

extensions/levelcode-ai/mcpConfig.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@ function serverMapOf(parsed) {
5555
return parsed;
5656
}
5757

58+
// Keys that must never be copied out of an untrusted config: assigning `__proto__` invokes the
59+
// prototype setter rather than creating a property, and `constructor`/`prototype` are the usual
60+
// companions. See safeEnvCopy.
61+
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
62+
63+
/**
64+
* Copy an untrusted env map. `.levelcode/mcp.json` is repo-authored, and JSON.parse creates a REAL own
65+
* `__proto__` key, so a plain Object.assign would hand it to the prototype setter instead of copying it.
66+
* The string-value check in normalizeServer already rejects the classic object-valued payload, which
67+
* makes today's safety incidental — this makes it structural, and stops an env var named `__proto__`
68+
* from silently vanishing into a setter. Deliberately a normal object, not Object.create(null): later
69+
* code (and tests) may reasonably call hasOwnProperty on it.
70+
*/
71+
function safeEnvCopy(raw) {
72+
const out = {};
73+
for (const k of Object.keys(raw || {})) {
74+
if (UNSAFE_KEYS.indexOf(k) !== -1) { continue; }
75+
out[k] = raw[k];
76+
}
77+
return out;
78+
}
79+
5880
/** Validate one entry. Returns a server object, or a string describing why it was rejected. */
5981
function normalizeServer(name, raw, source, origin) {
6082
if (!name || typeof name !== 'string') { return 'server name must be a non-empty string'; }
@@ -71,7 +93,7 @@ function normalizeServer(name, raw, source, origin) {
7193
name: name,
7294
command: raw.command,
7395
args: raw.args ? raw.args.slice() : [],
74-
env: raw.env ? Object.assign({}, raw.env) : {},
96+
env: raw.env ? safeEnvCopy(raw.env) : {},
7597
source: source, // 'settings' (user-authored) | 'workspace' (repo-authored, untrusted)
7698
origin: origin // human label for the consent card / problem messages
7799
};

extensions/levelcode-ai/test/mcpConfig.test.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,39 @@ test('ASSIGN: built-ins are reserved by default', () => {
8787
assert.ok(!M.BUILTIN_TOOL_NAMES.includes(tools[0].name));
8888
});
8989

90-
test('ASSIGN: two servers that sanitize alike still get distinct names', () => {
91-
const { tools } = M.assignToolNames([
92-
{ server: 'my-server', tool: 'go' },
93-
{ server: 'my/server', tool: 'go' } // sanitizes to my_server… as does the first? force the clash
90+
test('ASSIGN: two servers that sanitize to the SAME name still get distinct tool names', () => {
91+
// 'my/server' and 'my:server' BOTH sanitize to 'my_server' — a genuine collision. An earlier version
92+
// of this test paired 'my-server' with 'my/server', but '-' is already legal so they never collided:
93+
// the test passed without ever entering the dedupe path. Assert the precondition so it can't rot again.
94+
assert.strictEqual(
95+
M.namespaceToolName('my/server', 'go'), M.namespaceToolName('my:server', 'go'),
96+
'precondition: these inputs must actually collide, or this test proves nothing'
97+
);
98+
const { tools, problems } = M.assignToolNames([
99+
{ server: 'my/server', tool: 'go' },
100+
{ server: 'my:server', tool: 'go' }
94101
]);
95102
assert.strictEqual(tools.length, 2);
96-
assert.notStrictEqual(tools[0].name, tools[1].name);
103+
assert.notStrictEqual(tools[0].name, tools[1].name, 'the collision must be broken, not silently aliased');
104+
assert.ok(problems.some((p) => /already taken/.test(p.message)), 'the dedupe path must report it');
97105
for (const t of tools) { assert.ok(LEGAL.test(t.name)); }
98106
});
99107

108+
test('TRUST: an untrusted env can never reach the prototype setter', () => {
109+
// JSON.parse creates a REAL own "__proto__" key, so a plain Object.assign would hand it to the
110+
// prototype setter instead of copying it. The string-value check already rejects the object-valued
111+
// payload, but this makes the guarantee structural rather than incidental.
112+
const raw = JSON.parse('{"evil":{"command":"x","env":{"__proto__":"pwned","constructor":"no","SAFE":"ok"}}}');
113+
const { servers } = M.loadServerConfig({ settings: raw });
114+
const env = servers[0].env;
115+
assert.strictEqual(env.SAFE, 'ok', 'legitimate vars must survive');
116+
assert.ok(!Object.prototype.hasOwnProperty.call(env, '__proto__'), '__proto__ must not be copied through');
117+
assert.ok(!Object.prototype.hasOwnProperty.call(env, 'constructor'), 'constructor must not be copied through');
118+
assert.strictEqual(Object.getPrototypeOf(env), Object.prototype, 'the copy\'s prototype must not be retargeted');
119+
// @ts-expect-error — probing for global pollution
120+
assert.strictEqual({}.pwned, undefined, 'global Object.prototype must be untouched');
121+
});
122+
100123
test('ASSIGN: a server over the per-server tool cap has the surplus dropped, with a problem', () => {
101124
const pairs = [];
102125
for (let i = 0; i < M.MAX_TOOLS_PER_SERVER + 5; i++) { pairs.push({ server: 'big', tool: 'tool' + i }); }

0 commit comments

Comments
 (0)