Download src/utils/hooks/execHttpHook.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 8.87 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/hooks/execHttpHook.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/hooks/execHttpHook.ts
-
curl -L -o execHttpHook.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/hooks/execHttpHook.ts
8.87 kB
| 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 } | |
| } | |
| } | |