/ utils / hooks / execHttpHook.ts
execHttpHook.ts
  1  import axios from 'axios'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import { createCombinedAbortSignal } from '../combinedAbortSignal.js'
  4  import { logForDebugging } from '../debug.js'
  5  import { errorMessage } from '../errors.js'
  6  import { getProxyUrl, shouldBypassProxy } from '../proxy.js'
  7  // Import as namespace so spyOn works in tests (direct imports bypass spies)
  8  import * as settingsModule from '../settings/settings.js'
  9  import type { HttpHook } from '../settings/types.js'
 10  import { ssrfGuardedLookup } from './ssrfGuard.js'
 11  
 12  const 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   */
 21  async 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   */
 49  function 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   */
 64  function 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   */
 76  function 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   */
 89  function 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   */
123  export 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  }