init claude-code
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import axios from 'axios'
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import { createCombinedAbortSignal } from '../combinedAbortSignal.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import { getProxyUrl, shouldBypassProxy } from '../proxy.js'
|
||||
// Import as namespace so spyOn works in tests (direct imports bypass spies)
|
||||
import * as settingsModule from '../settings/settings.js'
|
||||
import type { HttpHook } from '../settings/types.js'
|
||||
import { ssrfGuardedLookup } from './ssrfGuard.js'
|
||||
|
||||
const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS)
|
||||
|
||||
/**
|
||||
* Get the sandbox proxy config for routing HTTP hook requests through the
|
||||
* sandbox network proxy when sandboxing is enabled.
|
||||
*
|
||||
* Uses dynamic import to avoid a static import cycle
|
||||
* (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook).
|
||||
*/
|
||||
async function getSandboxProxyConfig(): Promise<
|
||||
{ host: string; port: number; protocol: string } | undefined
|
||||
> {
|
||||
const { SandboxManager } = await import('../sandbox/sandbox-adapter.js')
|
||||
|
||||
if (!SandboxManager.isSandboxingEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Wait for the sandbox network proxy to finish initializing. In REPL mode,
|
||||
// SandboxManager.initialize() is fire-and-forget so the proxy may not be
|
||||
// ready yet when the first hook fires.
|
||||
await SandboxManager.waitForNetworkInitialization()
|
||||
|
||||
const proxyPort = SandboxManager.getProxyPort()
|
||||
if (!proxyPort) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { host: '127.0.0.1', port: proxyPort, protocol: 'http' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read HTTP hook allowlist restrictions from merged settings (all sources).
|
||||
* Follows the allowedMcpServers precedent: arrays concatenate across sources.
|
||||
* When allowManagedHooksOnly is set in managed settings, only admin-defined
|
||||
* hooks run anyway, so no separate lock-down boolean is needed here.
|
||||
*/
|
||||
function getHttpHookPolicy(): {
|
||||
allowedUrls: string[] | undefined
|
||||
allowedEnvVars: string[] | undefined
|
||||
} {
|
||||
const settings = settingsModule.getInitialSettings()
|
||||
return {
|
||||
allowedUrls: settings.allowedHttpHookUrls,
|
||||
allowedEnvVars: settings.httpHookAllowedEnvVars,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a URL against a pattern with * as a wildcard (any characters).
|
||||
* Same semantics as the MCP server allowlist patterns.
|
||||
*/
|
||||
function urlMatchesPattern(url: string, pattern: string): boolean {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regexStr = escaped.replace(/\*/g, '.*')
|
||||
return new RegExp(`^${regexStr}$`).test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip CR, LF, and NUL bytes from a header value to prevent HTTP header
|
||||
* injection (CRLF injection) via env var values or hook-configured header
|
||||
* templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise
|
||||
* inject a second header into the request.
|
||||
*/
|
||||
function sanitizeHeaderValue(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\r\n\x00]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env,
|
||||
* but only for variable names present in the allowlist. References to variables
|
||||
* not in the allowlist are replaced with empty strings to prevent exfiltration
|
||||
* of secrets via project-configured HTTP hooks.
|
||||
*
|
||||
* The result is sanitized to strip CR/LF/NUL bytes to prevent header injection.
|
||||
*/
|
||||
function interpolateEnvVars(
|
||||
value: string,
|
||||
allowedEnvVars: ReadonlySet<string>,
|
||||
): string {
|
||||
const interpolated = value.replace(
|
||||
/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g,
|
||||
(_, braced, unbraced) => {
|
||||
const varName = braced ?? unbraced
|
||||
if (!allowedEnvVars.has(varName)) {
|
||||
logForDebugging(
|
||||
`Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return ''
|
||||
}
|
||||
return process.env[varName] ?? ''
|
||||
},
|
||||
)
|
||||
return sanitizeHeaderValue(interpolated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an HTTP hook by POSTing the hook input JSON to the configured URL.
|
||||
* Returns the raw response for the caller to interpret.
|
||||
*
|
||||
* When sandboxing is enabled, requests are routed through the sandbox network
|
||||
* proxy which enforces the domain allowlist. The proxy returns HTTP 403 for
|
||||
* blocked domains.
|
||||
*
|
||||
* Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that
|
||||
* secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json.
|
||||
* Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved;
|
||||
* all other references are replaced with empty strings.
|
||||
*/
|
||||
export async function execHttpHook(
|
||||
hook: HttpHook,
|
||||
_hookEvent: HookEvent,
|
||||
jsonInput: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
ok: boolean
|
||||
statusCode?: number
|
||||
body: string
|
||||
error?: string
|
||||
aborted?: boolean
|
||||
}> {
|
||||
// Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics:
|
||||
// undefined → no restriction; [] → block all; non-empty → must match a pattern.
|
||||
const policy = getHttpHookPolicy()
|
||||
if (policy.allowedUrls !== undefined) {
|
||||
const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p))
|
||||
if (!matched) {
|
||||
const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls`
|
||||
logForDebugging(msg, { level: 'warn' })
|
||||
return { ok: false, body: '', error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMs = hook.timeout
|
||||
? hook.timeout * 1000
|
||||
: DEFAULT_HTTP_HOOK_TIMEOUT_MS
|
||||
|
||||
const { signal: combinedSignal, cleanup } = createCombinedAbortSignal(
|
||||
signal,
|
||||
{ timeoutMs },
|
||||
)
|
||||
|
||||
try {
|
||||
// Build headers with env var interpolation in values
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (hook.headers) {
|
||||
// Intersect hook's allowedEnvVars with policy allowlist when policy is set
|
||||
const hookVars = hook.allowedEnvVars ?? []
|
||||
const effectiveVars =
|
||||
policy.allowedEnvVars !== undefined
|
||||
? hookVars.filter(v => policy.allowedEnvVars!.includes(v))
|
||||
: hookVars
|
||||
const allowedEnvVars = new Set(effectiveVars)
|
||||
for (const [name, value] of Object.entries(hook.headers)) {
|
||||
headers[name] = interpolateEnvVars(value, allowedEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
// Route through sandbox network proxy when available. The proxy enforces
|
||||
// the domain allowlist and returns 403 for blocked domains.
|
||||
const sandboxProxy = await getSandboxProxyConfig()
|
||||
|
||||
// Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY).
|
||||
// When set, configureGlobalAgents() has already installed a request
|
||||
// interceptor that sets httpsAgent to an HttpsProxyAgent — the proxy
|
||||
// handles DNS for the target. Skip the SSRF guard in that case, same
|
||||
// as we do for the sandbox proxy, so that we don't accidentally block
|
||||
// a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128).
|
||||
const envProxyActive =
|
||||
!sandboxProxy &&
|
||||
getProxyUrl() !== undefined &&
|
||||
!shouldBypassProxy(hook.url)
|
||||
|
||||
if (sandboxProxy) {
|
||||
logForDebugging(
|
||||
`Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`,
|
||||
)
|
||||
} else if (envProxyActive) {
|
||||
logForDebugging(
|
||||
`Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`)
|
||||
}
|
||||
|
||||
const response = await axios.post<string>(hook.url, jsonInput, {
|
||||
headers,
|
||||
signal: combinedSignal,
|
||||
responseType: 'text',
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
// Explicit false prevents axios's own env-var proxy detection; when an
|
||||
// env-var proxy is configured, the global axios interceptor installed
|
||||
// by configureGlobalAgents() handles it via httpsAgent instead.
|
||||
proxy: sandboxProxy ?? false,
|
||||
// SSRF guard: validate resolved IPs, block private/link-local ranges
|
||||
// (but allow loopback for local dev). Skipped when any proxy is in
|
||||
// use — the proxy performs DNS for the target, and applying the
|
||||
// guard would instead validate the proxy's own IP, breaking
|
||||
// connections to corporate proxies on private networks.
|
||||
lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup,
|
||||
})
|
||||
|
||||
cleanup()
|
||||
|
||||
const body = response.data ?? ''
|
||||
logForDebugging(
|
||||
`Hooks: HTTP hook response status ${response.status}, body length ${body.length}`,
|
||||
)
|
||||
|
||||
return {
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
statusCode: response.status,
|
||||
body,
|
||||
}
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
|
||||
if (combinedSignal.aborted) {
|
||||
return { ok: false, body: '', aborted: true }
|
||||
}
|
||||
|
||||
const errorMsg = errorMessage(error)
|
||||
logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' })
|
||||
return { ok: false, body: '', error: errorMsg }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user