File size: 8,871 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | 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 }
}
}
|