fix(security): prevent shell injection in headersHelper (#36)

- Parse headersHelper command with shell-quote to reject operators
- Call execFileNoThrowWithCwd(cmd, args) without shell: true
- Remove shell option from ExecFileWithCwdOptions type entirely
- Add headersHelper.test.ts with injection rejection test
- Fix existing MCP test mocks for compatibility

Test: MCP tests 91/0, full suite 3058 pass (0 new failures)
This commit is contained in:
James Feng 2026-06-03 16:59:16 +08:00
parent f8c3354c75
commit 368dd99d01
5 changed files with 86 additions and 5 deletions

View File

@ -1,7 +1,12 @@
import { mock, describe, expect, test } from "bun:test"; import { mock, describe, expect, test } from "bun:test";
mock.module("src/utils/slowOperations.js", () => ({ mock.module("src/utils/slowOperations.js", () => ({
clone: structuredClone,
cloneDeep: structuredClone,
jsonParse: JSON.parse,
jsonStringify: (v: unknown) => JSON.stringify(v), jsonStringify: (v: unknown) => JSON.stringify(v),
slowLogging: () => ({ [Symbol.dispose]: () => {} }),
writeFileSync_DEPRECATED: () => {},
})); }));
mock.module("src/services/analytics/growthbook.js", () => ({ mock.module("src/services/analytics/growthbook.js", () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false, getFeatureValue_CACHED_MAY_BE_STALE: () => false,

View File

@ -0,0 +1,63 @@
import { describe, expect, mock, test } from 'bun:test'
import type { McpHTTPServerConfig } from '../types.js'
mock.module('src/utils/config.js', () => ({
checkHasTrustDialogAccepted: () => true,
}))
mock.module('src/utils/debug.js', () => ({
logAntError: () => {},
}))
mock.module('src/utils/log.js', () => ({
logError: () => {},
logMCPDebug: () => {},
logMCPError: () => {},
}))
mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
}))
mock.module('src/utils/slowOperations.js', () => ({
clone: structuredClone,
cloneDeep: structuredClone,
jsonParse: JSON.parse,
jsonStringify: JSON.stringify,
slowLogging: () => ({ [Symbol.dispose]: () => {} }),
writeFileSync_DEPRECATED: () => {},
}))
const { getMcpHeadersFromHelper } = await import('../headersHelper.js')
function makeConfig(headersHelper: string): McpHTTPServerConfig {
return {
type: 'http',
url: 'https://example.com/mcp',
headersHelper,
}
}
function validHelperCommand(value: string): string {
const script =
'console.log(JSON.stringify({ Authorization: process.argv[1] }))'
return [process.execPath, '-e', JSON.stringify(script), JSON.stringify(value)].join(
' ',
)
}
describe('getMcpHeadersFromHelper', () => {
test('executes helper commands with quoted arguments without a shell', async () => {
const headers = await getMcpHeadersFromHelper(
'test-server',
makeConfig(validHelperCommand('Bearer token with spaces')),
)
expect(headers).toEqual({ Authorization: 'Bearer token with spaces' })
})
test('rejects shell operators in helper commands', async () => {
const headers = await getMcpHeadersFromHelper(
'test-server',
makeConfig(`${validHelperCommand('safe')} ; true`),
)
expect(headers).toBeNull()
})
})

View File

@ -4,6 +4,7 @@ mock.module("axios", () => ({
default: { get: async () => ({ data: { servers: [] } }) }, default: { get: async () => ({ data: { servers: [] } }) },
})); }));
mock.module("src/utils/debug.js", () => ({ mock.module("src/utils/debug.js", () => ({
logAntError: () => {},
logForDebugging: () => {}, logForDebugging: () => {},
})); }));
mock.module("src/utils/errors.js", () => ({ mock.module("src/utils/errors.js", () => ({

View File

@ -1,3 +1,4 @@
import { parse as shellParse } from 'shell-quote'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js' import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { checkHasTrustDialogAccepted } from '../../utils/config.js' import { checkHasTrustDialogAccepted } from '../../utils/config.js'
import { logAntError } from '../../utils/debug.js' import { logAntError } from '../../utils/debug.js'
@ -58,8 +59,22 @@ export async function getMcpHeadersFromHelper(
try { try {
logMCPDebug(serverName, 'Executing headersHelper to get dynamic headers') logMCPDebug(serverName, 'Executing headersHelper to get dynamic headers')
const execResult = await execFileNoThrowWithCwd(config.headersHelper, [], {
shell: true, // Parse the headersHelper command string into tokens.
// Reject any input containing shell operators to prevent injection.
const tokens = shellParse(config.headersHelper)
if (!tokens.every((t: unknown) => typeof t === 'string')) {
throw new Error(
`headersHelper must be an executable path with arguments only, no shell operators allowed`,
)
}
const commandTokens = tokens as string[]
if (commandTokens.length === 0) {
throw new Error('headersHelper command is empty')
}
const [cmd, ...args] = commandTokens
const execResult = await execFileNoThrowWithCwd(cmd, args, {
timeout: 10000, timeout: 10000,
// Pass server context so one helper script can serve multiple MCP servers // Pass server context so one helper script can serve multiple MCP servers
// (git credential-helper style). See deshaw/anthropic-issues#28. // (git credential-helper style). See deshaw/anthropic-issues#28.

View File

@ -50,7 +50,6 @@ type ExecFileWithCwdOptions = {
maxBuffer?: number maxBuffer?: number
cwd?: string cwd?: string
env?: NodeJS.ProcessEnv env?: NodeJS.ProcessEnv
shell?: boolean | string | undefined
stdin?: 'ignore' | 'inherit' | 'pipe' stdin?: 'ignore' | 'inherit' | 'pipe'
input?: string input?: string
} }
@ -96,7 +95,6 @@ export function execFileNoThrowWithCwd(
cwd: finalCwd, cwd: finalCwd,
env: finalEnv, env: finalEnv,
maxBuffer, maxBuffer,
shell,
stdin: finalStdin, stdin: finalStdin,
input: finalInput, input: finalInput,
}: ExecFileWithCwdOptions = { }: ExecFileWithCwdOptions = {
@ -113,7 +111,6 @@ export function execFileNoThrowWithCwd(
timeout: finalTimeout, timeout: finalTimeout,
cwd: finalCwd, cwd: finalCwd,
env: finalEnv, env: finalEnv,
shell,
stdin: finalStdin, stdin: finalStdin,
input: finalInput, input: finalInput,
reject: false, // Don't throw on non-zero exit codes reject: false, // Don't throw on non-zero exit codes