Skip to content

Commit a4469d6

Browse files
cliffhallclaude
andcommitted
fix(servers): pass-4 review — strip smuggled settings out of config
Pass-4 review at #1353 (comment). Main finding: `normalizeServerType` spreads unknown keys, so a body that nested `settings` inside `config` would smuggle a settings field onto the stored entry without ever passing through `validateSettings`. Strip the settings key off the incoming config in `buildStoredEntry` so `validateSettings` remains the single write path for the settings node — the "one source of truth" invariant pass 2 set up holds again. Two new tests pin the strip on both routes: - POST with `config.settings = { bogus }` lands an entry with no settings node on disk. - PUT with `config.settings = { bogus }` + `settings: null` clears the real settings *and* doesn't re-attach the bogus payload via the spread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60189ff commit a4469d6

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

clients/web/src/test/integration/mcp/remote/servers-route.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,69 @@ describe("/api/servers routes", () => {
605605
expect(res.status).toBe(400);
606606
});
607607

608+
it("strips smuggled config.settings on POST (validateSettings is the only write path)", async () => {
609+
// `normalizeServerType` spreads unknown keys verbatim. Without the
610+
// strip in buildStoredEntry, a body that nests `settings` inside
611+
// `config` would land it on the stored entry without ever passing
612+
// through `validateSettings`. Pin the strip.
613+
const res = await fetch(`${h.baseUrl}/api/servers`, {
614+
method: "POST",
615+
headers: { "Content-Type": "application/json" },
616+
body: JSON.stringify({
617+
id: "smuggle-post",
618+
config: {
619+
type: "stdio",
620+
command: "node",
621+
settings: { bogus: true },
622+
},
623+
}),
624+
});
625+
expect(res.status).toBe(200);
626+
const stored = readConfig(h.configPath).mcpServers["smuggle-post"] as {
627+
settings?: unknown;
628+
};
629+
expect(stored.settings).toBeUndefined();
630+
});
631+
632+
it("strips smuggled config.settings on PUT even when settings:null clears the real node", async () => {
633+
writeFileSync(
634+
h.configPath,
635+
JSON.stringify({
636+
mcpServers: {
637+
smuggle: {
638+
type: "stdio",
639+
command: "node",
640+
settings: {
641+
headers: [{ key: "X-Real", value: "yes" }],
642+
metadata: [],
643+
connectionTimeout: 0,
644+
requestTimeout: 0,
645+
},
646+
},
647+
},
648+
}),
649+
);
650+
// settings: null clears the real settings node; the bogus key nested
651+
// under config must not re-attach via the spread inside normalizeServerType.
652+
const res = await fetch(`${h.baseUrl}/api/servers/smuggle`, {
653+
method: "PUT",
654+
headers: { "Content-Type": "application/json" },
655+
body: JSON.stringify({
656+
config: {
657+
type: "stdio",
658+
command: "node",
659+
settings: { bogus: true },
660+
},
661+
settings: null,
662+
}),
663+
});
664+
expect(res.status).toBe(200);
665+
const stored = readConfig(h.configPath).mcpServers.smuggle as {
666+
settings?: unknown;
667+
};
668+
expect(stored.settings).toBeUndefined();
669+
});
670+
608671
it("rejects a settings array (not an object) with 400", async () => {
609672
const res = await fetch(`${h.baseUrl}/api/servers`, {
610673
method: "POST",

core/mcp/remote/node/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,12 +662,20 @@ export function createRemoteApp(
662662

663663
// Build a single on-disk entry from `{ config, settings }`, normalizing the
664664
// type discriminator and attaching settings only when defined.
665+
//
666+
// `normalizeServerType` spreads unknown keys from the incoming config
667+
// through verbatim, so a caller that included `config.settings` on the
668+
// wire would smuggle a `settings` field straight onto the stored entry —
669+
// bypassing `validateSettings`. Strip it here so `validateSettings`
670+
// remains the single write path for the settings node.
665671
const buildStoredEntry = (
666672
config: unknown,
667673
settings: InspectorServerSettings | undefined,
668674
): StoredMCPServer => {
675+
const { settings: _smuggled, ...configOnly } =
676+
(config as Record<string, unknown>) ?? {};
669677
const normalized = normalizeServerType(
670-
config as Record<string, unknown> & { type?: string },
678+
configOnly as Record<string, unknown> & { type?: string },
671679
) as StoredMCPServer;
672680
if (settings !== undefined) {
673681
normalized.settings = settings;

0 commit comments

Comments
 (0)