source dump of claude code
at main 242 lines 8.9 kB view raw
1import axios from 'axios' 2import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' 3import { createCombinedAbortSignal } from '../combinedAbortSignal.js' 4import { logForDebugging } from '../debug.js' 5import { errorMessage } from '../errors.js' 6import { getProxyUrl, shouldBypassProxy } from '../proxy.js' 7// Import as namespace so spyOn works in tests (direct imports bypass spies) 8import * as settingsModule from '../settings/settings.js' 9import type { HttpHook } from '../settings/types.js' 10import { ssrfGuardedLookup } from './ssrfGuard.js' 11 12const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS) 13 14/** 15 * Get the sandbox proxy config for routing HTTP hook requests through the 16 * sandbox network proxy when sandboxing is enabled. 17 * 18 * Uses dynamic import to avoid a static import cycle 19 * (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook). 20 */ 21async function getSandboxProxyConfig(): Promise< 22 { host: string; port: number; protocol: string } | undefined 23> { 24 const { SandboxManager } = await import('../sandbox/sandbox-adapter.js') 25 26 if (!SandboxManager.isSandboxingEnabled()) { 27 return undefined 28 } 29 30 // Wait for the sandbox network proxy to finish initializing. In REPL mode, 31 // SandboxManager.initialize() is fire-and-forget so the proxy may not be 32 // ready yet when the first hook fires. 33 await SandboxManager.waitForNetworkInitialization() 34 35 const proxyPort = SandboxManager.getProxyPort() 36 if (!proxyPort) { 37 return undefined 38 } 39 40 return { host: '127.0.0.1', port: proxyPort, protocol: 'http' } 41} 42 43/** 44 * Read HTTP hook allowlist restrictions from merged settings (all sources). 45 * Follows the allowedMcpServers precedent: arrays concatenate across sources. 46 * When allowManagedHooksOnly is set in managed settings, only admin-defined 47 * hooks run anyway, so no separate lock-down boolean is needed here. 48 */ 49function getHttpHookPolicy(): { 50 allowedUrls: string[] | undefined 51 allowedEnvVars: string[] | undefined 52} { 53 const settings = settingsModule.getInitialSettings() 54 return { 55 allowedUrls: settings.allowedHttpHookUrls, 56 allowedEnvVars: settings.httpHookAllowedEnvVars, 57 } 58} 59 60/** 61 * Match a URL against a pattern with * as a wildcard (any characters). 62 * Same semantics as the MCP server allowlist patterns. 63 */ 64function urlMatchesPattern(url: string, pattern: string): boolean { 65 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') 66 const regexStr = escaped.replace(/\*/g, '.*') 67 return new RegExp(`^${regexStr}$`).test(url) 68} 69 70/** 71 * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header 72 * injection (CRLF injection) via env var values or hook-configured header 73 * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise 74 * inject a second header into the request. 75 */ 76function sanitizeHeaderValue(value: string): string { 77 // eslint-disable-next-line no-control-regex 78 return value.replace(/[\r\n\x00]/g, '') 79} 80 81/** 82 * Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env, 83 * but only for variable names present in the allowlist. References to variables 84 * not in the allowlist are replaced with empty strings to prevent exfiltration 85 * of secrets via project-configured HTTP hooks. 86 * 87 * The result is sanitized to strip CR/LF/NUL bytes to prevent header injection. 88 */ 89function interpolateEnvVars( 90 value: string, 91 allowedEnvVars: ReadonlySet<string>, 92): string { 93 const interpolated = value.replace( 94 /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, 95 (_, braced, unbraced) => { 96 const varName = braced ?? unbraced 97 if (!allowedEnvVars.has(varName)) { 98 logForDebugging( 99 `Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`, 100 { level: 'warn' }, 101 ) 102 return '' 103 } 104 return process.env[varName] ?? '' 105 }, 106 ) 107 return sanitizeHeaderValue(interpolated) 108} 109 110/** 111 * Execute an HTTP hook by POSTing the hook input JSON to the configured URL. 112 * Returns the raw response for the caller to interpret. 113 * 114 * When sandboxing is enabled, requests are routed through the sandbox network 115 * proxy which enforces the domain allowlist. The proxy returns HTTP 403 for 116 * blocked domains. 117 * 118 * Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that 119 * secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json. 120 * Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved; 121 * all other references are replaced with empty strings. 122 */ 123export async function execHttpHook( 124 hook: HttpHook, 125 _hookEvent: HookEvent, 126 jsonInput: string, 127 signal?: AbortSignal, 128): Promise<{ 129 ok: boolean 130 statusCode?: number 131 body: string 132 error?: string 133 aborted?: boolean 134}> { 135 // Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics: 136 // undefined → no restriction; [] → block all; non-empty → must match a pattern. 137 const policy = getHttpHookPolicy() 138 if (policy.allowedUrls !== undefined) { 139 const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p)) 140 if (!matched) { 141 const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls` 142 logForDebugging(msg, { level: 'warn' }) 143 return { ok: false, body: '', error: msg } 144 } 145 } 146 147 const timeoutMs = hook.timeout 148 ? hook.timeout * 1000 149 : DEFAULT_HTTP_HOOK_TIMEOUT_MS 150 151 const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( 152 signal, 153 { timeoutMs }, 154 ) 155 156 try { 157 // Build headers with env var interpolation in values 158 const headers: Record<string, string> = { 159 'Content-Type': 'application/json', 160 } 161 if (hook.headers) { 162 // Intersect hook's allowedEnvVars with policy allowlist when policy is set 163 const hookVars = hook.allowedEnvVars ?? [] 164 const effectiveVars = 165 policy.allowedEnvVars !== undefined 166 ? hookVars.filter(v => policy.allowedEnvVars!.includes(v)) 167 : hookVars 168 const allowedEnvVars = new Set(effectiveVars) 169 for (const [name, value] of Object.entries(hook.headers)) { 170 headers[name] = interpolateEnvVars(value, allowedEnvVars) 171 } 172 } 173 174 // Route through sandbox network proxy when available. The proxy enforces 175 // the domain allowlist and returns 403 for blocked domains. 176 const sandboxProxy = await getSandboxProxyConfig() 177 178 // Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY). 179 // When set, configureGlobalAgents() has already installed a request 180 // interceptor that sets httpsAgent to an HttpsProxyAgent — the proxy 181 // handles DNS for the target. Skip the SSRF guard in that case, same 182 // as we do for the sandbox proxy, so that we don't accidentally block 183 // a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128). 184 const envProxyActive = 185 !sandboxProxy && 186 getProxyUrl() !== undefined && 187 !shouldBypassProxy(hook.url) 188 189 if (sandboxProxy) { 190 logForDebugging( 191 `Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`, 192 ) 193 } else if (envProxyActive) { 194 logForDebugging( 195 `Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`, 196 ) 197 } else { 198 logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`) 199 } 200 201 const response = await axios.post<string>(hook.url, jsonInput, { 202 headers, 203 signal: combinedSignal, 204 responseType: 'text', 205 validateStatus: () => true, 206 maxRedirects: 0, 207 // Explicit false prevents axios's own env-var proxy detection; when an 208 // env-var proxy is configured, the global axios interceptor installed 209 // by configureGlobalAgents() handles it via httpsAgent instead. 210 proxy: sandboxProxy ?? false, 211 // SSRF guard: validate resolved IPs, block private/link-local ranges 212 // (but allow loopback for local dev). Skipped when any proxy is in 213 // use — the proxy performs DNS for the target, and applying the 214 // guard would instead validate the proxy's own IP, breaking 215 // connections to corporate proxies on private networks. 216 lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup, 217 }) 218 219 cleanup() 220 221 const body = response.data ?? '' 222 logForDebugging( 223 `Hooks: HTTP hook response status ${response.status}, body length ${body.length}`, 224 ) 225 226 return { 227 ok: response.status >= 200 && response.status < 300, 228 statusCode: response.status, 229 body, 230 } 231 } catch (error) { 232 cleanup() 233 234 if (combinedSignal.aborted) { 235 return { ok: false, body: '', aborted: true } 236 } 237 238 const errorMsg = errorMessage(error) 239 logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' }) 240 return { ok: false, body: '', error: errorMsg } 241 } 242}