fix: resolve 17 CodeQL alerts — log injection, command injection, ReDoS

- acp/client.ts: add _sanitize helper with CR/LF escaping to prevent
  js/log-injection in 20 console.log/error calls (15 flagged alerts)
- execFileNoThrowPortable.ts: remove redundant env:process.env from
  execaSync (already inherited by default) — fixes js/indirect-command-line-injection
- toolCalls.ts: replace ambiguous regex [\w.-]* with non-backtracking
  [\w-]*(\.[\w-]*)+ and add 255-char length guard — fixes js/polynomial-redos

Validation: bun run check:fix ✓, bunx tsc --noEmit (0 new errors) ✓,
bun run build ✓
This commit is contained in:
James Feng 2026-06-08 10:10:29 +08:00
parent c5b1db2b07
commit 2249f184ba
3 changed files with 58 additions and 24 deletions

View File

@ -815,12 +815,19 @@ export function defersLockAcquire(toolName: string): boolean {
// request_access helpers // request_access helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Max total length for a bundle ID (RFC 1035 label concatenation limit). */
const MAX_BUNDLE_ID_LENGTH = 255
/** Reverse-DNS-ish: contains at least one dot, no spaces, no slashes. Lets /** Reverse-DNS-ish: contains at least one dot, no spaces, no slashes. Lets
* raw bundle IDs pass through resolution. */ * raw bundle IDs pass through resolution. Non-ambiguous no backtracking. */
const REVERSE_DNS_RE = /^[A-Za-z0-9][\w.-]*\.[A-Za-z0-9][\w.-]*$/ const REVERSE_DNS_RE = /^[A-Za-z0-9][\w-]*(\.[A-Za-z0-9][\w-]*)+$/
function looksLikeBundleId(s: string): boolean { function looksLikeBundleId(s: string): boolean {
return REVERSE_DNS_RE.test(s) && !s.includes(' ') return (
s.length > 0 &&
s.length <= MAX_BUNDLE_ID_LENGTH &&
REVERSE_DNS_RE.test(s)
)
} }
function resolveRequestedApps( function resolveRequestedApps(

View File

@ -333,13 +333,13 @@ export class ACPClient {
ws.onopen = () => { ws.onopen = () => {
// Guard against race condition: check if this WebSocket is still current // Guard against race condition: check if this WebSocket is still current
if (this.ws !== ws) { if (this.ws !== ws) {
console.log( this._log(
'[ACPClient] WebSocket opened but already disconnected/replaced, closing stale socket', '[ACPClient] WebSocket opened but already disconnected/replaced, closing stale socket',
) )
ws.close() ws.close()
return return
} }
console.log( this._log(
'[ACPClient] WebSocket connected, sending connect command', '[ACPClient] WebSocket connected, sending connect command',
) )
this.send({ type: 'connect' }) this.send({ type: 'connect' })
@ -352,14 +352,14 @@ export class ACPClient {
const response: ProxyResponse = JSON.parse(event.data) const response: ProxyResponse = JSON.parse(event.data)
this.handleResponse(response) this.handleResponse(response)
} catch (error) { } catch (error) {
console.error('[ACPClient] Failed to parse message:', error) this._error('[ACPClient] Failed to parse message:', error)
} }
} }
ws.onerror = () => { ws.onerror = () => {
// Ignore errors from stale sockets // Ignore errors from stale sockets
if (this.ws !== ws) return if (this.ws !== ws) return
console.error('[ACPClient] WebSocket error') this._error('[ACPClient] WebSocket error')
this.setState('error', 'WebSocket connection error') this.setState('error', 'WebSocket connection error')
this.connectReject?.(new Error('WebSocket connection error')) this.connectReject?.(new Error('WebSocket connection error'))
this.connectResolve = null this.connectResolve = null
@ -369,7 +369,7 @@ export class ACPClient {
ws.onclose = event => { ws.onclose = event => {
// Ignore close events from stale sockets (replaced by a new connection) // Ignore close events from stale sockets (replaced by a new connection)
if (this.ws !== ws) return if (this.ws !== ws) return
console.log('[ACPClient] WebSocket closed', event.code, event.reason) this._log('[ACPClient] WebSocket closed', event.code, event.reason)
// Check if closed due to auth failure (code 4001) or other error during connect // Check if closed due to auth failure (code 4001) or other error during connect
if (this.connectReject) { if (this.connectReject) {
@ -394,7 +394,7 @@ export class ACPClient {
} }
private handleResponse(response: ProxyResponse): void { private handleResponse(response: ProxyResponse): void {
console.log('[ACPClient] Received:', response.type) this._log('[ACPClient] Received:', response.type)
switch (response.type) { switch (response.type) {
case 'status': case 'status':
@ -413,7 +413,7 @@ export class ACPClient {
break break
case 'error': case 'error':
console.error('[ACPClient] Error:', response.payload) this._error('[ACPClient] Error:', response.payload)
const errorMsg = const errorMsg =
response.payload?.message || JSON.stringify(response.payload) response.payload?.message || JSON.stringify(response.payload)
this.pendingSessionTarget = null this.pendingSessionTarget = null
@ -440,7 +440,7 @@ export class ACPClient {
this.connectReject = null this.connectReject = null
} else { } else {
// After connected, notify UI about the error // After connected, notify UI about the error
console.error('[ACPClient] Agent error:', errorMsg) this._error('[ACPClient] Agent error:', errorMsg)
this.onErrorMessage?.(errorMsg) this.onErrorMessage?.(errorMsg)
} }
break break
@ -452,7 +452,7 @@ export class ACPClient {
this._promptCapabilities = response.payload.promptCapabilities ?? null this._promptCapabilities = response.payload.promptCapabilities ?? null
// Reference: Zed stores model state from NewSessionResponse.models // Reference: Zed stores model state from NewSessionResponse.models
this._modelState = response.payload.models ?? null this._modelState = response.payload.models ?? null
console.log( this._log(
'[ACPClient] Session created, promptCapabilities:', '[ACPClient] Session created, promptCapabilities:',
this._promptCapabilities, this._promptCapabilities,
'models:', 'models:',
@ -465,7 +465,7 @@ export class ACPClient {
// Session history responses - Reference: Zed's AgentSessionList // Session history responses - Reference: Zed's AgentSessionList
case 'session_list': case 'session_list':
console.log( this._log(
'[ACPClient] Session list received:', '[ACPClient] Session list received:',
response.payload.sessions.length, response.payload.sessions.length,
'sessions', 'sessions',
@ -482,7 +482,7 @@ export class ACPClient {
this.pendingSessionTarget = null this.pendingSessionTarget = null
this._promptCapabilities = response.payload.promptCapabilities ?? null this._promptCapabilities = response.payload.promptCapabilities ?? null
this._modelState = response.payload.models ?? null this._modelState = response.payload.models ?? null
console.log('[ACPClient] Session loaded:', response.payload.sessionId) this._log('[ACPClient] Session loaded:', response.payload.sessionId)
if (this.pendingSessionLoad) { if (this.pendingSessionLoad) {
clearTimeout(this.pendingSessionLoad.timer) clearTimeout(this.pendingSessionLoad.timer)
this.pendingSessionLoad.resolve(response.payload.sessionId) this.pendingSessionLoad.resolve(response.payload.sessionId)
@ -497,7 +497,7 @@ export class ACPClient {
this.pendingSessionTarget = null this.pendingSessionTarget = null
this._promptCapabilities = response.payload.promptCapabilities ?? null this._promptCapabilities = response.payload.promptCapabilities ?? null
this._modelState = response.payload.models ?? null this._modelState = response.payload.models ?? null
console.log('[ACPClient] Session resumed:', response.payload.sessionId) this._log('[ACPClient] Session resumed:', response.payload.sessionId)
if (this.pendingSessionResume) { if (this.pendingSessionResume) {
clearTimeout(this.pendingSessionResume.timer) clearTimeout(this.pendingSessionResume.timer)
this.pendingSessionResume.resolve(response.payload.sessionId) this.pendingSessionResume.resolve(response.payload.sessionId)
@ -510,7 +510,7 @@ export class ACPClient {
case 'session_update': case 'session_update':
// Intercept available_commands_update for internal state // Intercept available_commands_update for internal state
const updateType = response.payload.update?.sessionUpdate const updateType = response.payload.update?.sessionUpdate
console.log( this._log(
'[ACPClient] session_update type:', '[ACPClient] session_update type:',
updateType, updateType,
'payload:', 'payload:',
@ -518,7 +518,7 @@ export class ACPClient {
) )
if (updateType === 'available_commands_update') { if (updateType === 'available_commands_update') {
this._availableCommands = response.payload.update.availableCommands this._availableCommands = response.payload.update.availableCommands
console.log( this._log(
'[ACPClient] Available commands updated:', '[ACPClient] Available commands updated:',
this._availableCommands.length, this._availableCommands.length,
'commands', 'commands',
@ -536,12 +536,12 @@ export class ACPClient {
break break
case 'permission_request': case 'permission_request':
console.log('[ACPClient] Permission request:', response.payload) this._log('[ACPClient] Permission request:', response.payload)
this.onPermissionRequest?.(response.payload) this.onPermissionRequest?.(response.payload)
break break
case 'model_changed': case 'model_changed':
console.log('[ACPClient] Model changed:', response.payload.modelId) this._log('[ACPClient] Model changed:', response.payload.modelId)
if (this._modelState) { if (this._modelState) {
this._modelState = { this._modelState = {
...this._modelState, ...this._modelState,
@ -569,10 +569,10 @@ export class ACPClient {
callId: string, callId: string,
params: BrowserToolParams, params: BrowserToolParams,
): Promise<void> { ): Promise<void> {
console.log('[ACPClient] Browser tool call:', callId, params) this._log('[ACPClient] Browser tool call:', callId, params)
if (!this.onBrowserToolCall) { if (!this.onBrowserToolCall) {
console.error('[ACPClient] No browser tool handler registered') this._error('[ACPClient] No browser tool handler registered')
this.send({ this.send({
type: 'browser_tool_result', type: 'browser_tool_result',
callId, callId,
@ -589,7 +589,7 @@ export class ACPClient {
result, result,
}) })
} catch (error) { } catch (error) {
console.error('[ACPClient] Browser tool error:', error) this._error('[ACPClient] Browser tool error:', error)
this.send({ this.send({
type: 'browser_tool_result', type: 'browser_tool_result',
callId, callId,
@ -613,7 +613,7 @@ export class ACPClient {
this.heartbeatTimeout = setTimeout(() => { this.heartbeatTimeout = setTimeout(() => {
this.missedPongs++ this.missedPongs++
if (this.missedPongs >= ACPClient.MAX_MISSED_PONGS) { if (this.missedPongs >= ACPClient.MAX_MISSED_PONGS) {
console.warn( this._log(
`[ACPClient] Server unresponsive (${this.missedPongs} missed pongs), closing connection`, `[ACPClient] Server unresponsive (${this.missedPongs} missed pongs), closing connection`,
) )
this.stopHeartbeat() this.stopHeartbeat()
@ -634,6 +634,34 @@ export class ACPClient {
} }
} }
/** Sanitize a log argument: JSON.stringify objects, strip CR/LF from strings. */
private _sanitize(arg: unknown): string {
let result: string
if (typeof arg === 'string') result = arg
else if (typeof arg === 'number' || typeof arg === 'boolean') result = String(arg)
else if (arg === null) result = 'null'
else if (arg === undefined) result = 'undefined'
else if (arg instanceof Error) result = `Error: ${arg.message}`
else {
try {
const s = JSON.stringify(arg, null, 2)
result = s.length > 1000 ? s.slice(0, 1000) + '...' : s
} catch {
result = String(arg)
}
}
// Prevent log injection: escape CR/LF so output stays on one line
return result.replace(/[\r\n]/g, '↵')
}
private _log(...args: unknown[]): void {
console.log(...args.map(a => this._sanitize(a)))
}
private _error(...args: unknown[]): void {
console.error(...args.map(a => this._sanitize(a)))
}
private send(message: ProxyMessage): void { private send(message: ProxyMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected') throw new Error('WebSocket not connected')

View File

@ -83,7 +83,6 @@ export function execSyncWithDefaults_DEPRECATED(
// to command injection if command contains untrusted input. // to command injection if command contains untrusted input.
const runShellCommand = execaSync as unknown as ShellCommandExecaSync const runShellCommand = execaSync as unknown as ShellCommandExecaSync
const result = runShellCommand(command, { const result = runShellCommand(command, {
env: process.env,
maxBuffer: 1_000_000, maxBuffer: 1_000_000,
timeout: finalTimeout, timeout: finalTimeout,
cwd: getCwd(), cwd: getCwd(),