Skip to main content

ai_agent/utils/hooks/
exec_http_hook.rs

1// Source: ~/claudecode/openclaudecode/src/utils/hooks/execHttpHook.ts
2#![allow(dead_code)]
3
4use std::collections::HashSet;
5use std::time::Duration;
6
7use reqwest::Client;
8use url::Url;
9
10use crate::utils::hooks::ssrf_guard::ssrf_guarded_lookup;
11use crate::utils::http::get_user_agent;
12
13/// Default HTTP hook timeout: 10 minutes
14const DEFAULT_HTTP_HOOK_TIMEOUT_MS: u64 = 10 * 60 * 1000;
15
16/// Represents an HTTP hook configuration
17pub struct HttpHook {
18    /// URL to POST to
19    pub url: String,
20    /// Optional timeout in seconds
21    pub timeout: Option<u64>,
22    /// Optional headers to include
23    pub headers: Option<std::collections::HashMap<String, String>>,
24    /// Allowed env vars for interpolation
25    pub allowed_env_vars: Option<Vec<String>>,
26}
27
28/// Result of an HTTP hook execution
29pub struct HttpHookResult {
30    pub ok: bool,
31    pub status_code: Option<u16>,
32    pub body: String,
33    pub error: Option<String>,
34    pub aborted: bool,
35}
36
37/// HTTP hook policy from settings
38struct HttpHookPolicy {
39    allowed_urls: Option<Vec<String>>,
40    allowed_env_vars: Option<Vec<String>>,
41}
42
43/// Get HTTP hook allowlist restrictions from settings
44fn get_http_hook_policy() -> HttpHookPolicy {
45    // In a real implementation, this would read from merged settings
46    HttpHookPolicy {
47        allowed_urls: None, // None means no restriction
48        allowed_env_vars: None,
49    }
50}
51
52/// Match a URL against a pattern with * as a wildcard (any characters)
53fn url_matches_pattern(url: &str, pattern: &str) -> bool {
54    // Escape regex special chars, then replace * with .*
55    let escaped = regex::escape(pattern);
56    let regex_str = escaped.replace("\\*", ".*");
57    match regex::Regex::new(&format!("^{}$", regex_str)) {
58        Ok(re) => re.is_match(url),
59        Err(_) => false,
60    }
61}
62
63/// Strip CR, LF, and NUL bytes from a header value to prevent HTTP header injection
64fn sanitize_header_value(value: &str) -> String {
65    value.replace(|c: char| c == '\r' || c == '\n' || c == '\0', "")
66}
67
68/// Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env,
69/// but only for variable names present in the allowlist
70fn interpolate_env_vars(value: &str, allowed_env_vars: &HashSet<String>) -> String {
71    let re = regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)").unwrap();
72
73    let interpolated = re.replace_all(value, |caps: &regex::Captures| {
74        let var_name = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
75        if let Some(name) = var_name {
76            if !allowed_env_vars.contains(name) {
77                log_for_debugging(&format!(
78                    "Hooks: env var ${} not in allowedEnvVars, skipping interpolation",
79                    name
80                ));
81                return String::new();
82            }
83            // Get env var value
84            if let Ok(val) = std::env::var(name) {
85                return val;
86            }
87        }
88        String::new()
89    });
90
91    sanitize_header_value(&interpolated)
92}
93
94/// Execute an HTTP hook by POSTing the hook input JSON to the configured URL
95pub async fn exec_http_hook(
96    hook: &HttpHook,
97    _hook_event: &str,
98    json_input: &str,
99) -> HttpHookResult {
100    // Enforce URL allowlist before any I/O
101    let policy = get_http_hook_policy();
102    if let Some(ref allowed_urls) = policy.allowed_urls {
103        let matched = allowed_urls
104            .iter()
105            .any(|p| url_matches_pattern(&hook.url, p));
106        if !matched {
107            let msg = format!(
108                "HTTP hook blocked: {} does not match any pattern in allowedHttpHookUrls",
109                hook.url
110            );
111            log_for_debugging(&msg);
112            return HttpHookResult {
113                ok: false,
114                status_code: None,
115                body: String::new(),
116                error: Some(msg),
117                aborted: false,
118            };
119        }
120    }
121
122    let timeout_ms = hook
123        .timeout
124        .map_or(DEFAULT_HTTP_HOOK_TIMEOUT_MS, |t| t * 1000);
125
126    // Build headers with env var interpolation in values
127    let mut headers = reqwest::header::HeaderMap::new();
128    headers.insert(
129        reqwest::header::CONTENT_TYPE,
130        "application/json".parse().unwrap(),
131    );
132    headers.insert("User-Agent", get_user_agent().parse().unwrap());
133
134    if let Some(ref hook_headers) = hook.headers {
135        // Intersect hook's allowed_env_vars with policy allowlist when policy is set
136        let hook_vars = hook.allowed_env_vars.clone().unwrap_or_default();
137        let effective_vars = if let Some(ref policy_vars) = policy.allowed_env_vars {
138            hook_vars
139                .into_iter()
140                .filter(|v| policy_vars.contains(v))
141                .collect::<Vec<_>>()
142        } else {
143            hook_vars
144        };
145        let allowed_env_vars: HashSet<String> = effective_vars.into_iter().collect();
146
147        for (name, value) in hook_headers {
148            let interpolated = interpolate_env_vars(value, &allowed_env_vars);
149            if let Ok(header_value) = reqwest::header::HeaderValue::from_str(&interpolated) {
150                if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
151                    headers.insert(header_name, header_value);
152                }
153            }
154        }
155    }
156
157    // Build client with timeout and optional proxy configuration
158    let mut client_builder = Client::builder().timeout(Duration::from_millis(timeout_ms));
159
160    // Configure proxy if available (would read from HTTP_PROXY/HTTPS_PROXY env vars)
161    if let Ok(proxy_url) = std::env::var("HTTP_PROXY") {
162        if let Ok(proxy) = reqwest::Proxy::http(&proxy_url) {
163            client_builder = client_builder.proxy(proxy);
164        }
165    }
166    if let Ok(proxy_url) = std::env::var("HTTPS_PROXY") {
167        if let Ok(proxy) = reqwest::Proxy::https(&proxy_url) {
168            client_builder = client_builder.proxy(proxy);
169        }
170    }
171
172    // Check if env proxy is active
173    let env_proxy_active = is_env_proxy_active() && !should_bypass_proxy(&hook.url);
174
175    if env_proxy_active {
176        log_for_debugging(&format!(
177            "Hooks: HTTP hook POST to {} (via env-var proxy)",
178            hook.url
179        ));
180    } else {
181        log_for_debugging(&format!("Hooks: HTTP hook POST to {}", hook.url));
182    }
183
184    let client = match client_builder.build() {
185        Ok(c) => c,
186        Err(e) => {
187            return HttpHookResult {
188                ok: false,
189                status_code: None,
190                body: String::new(),
191                error: Some(format!("Failed to build HTTP client: {}", e)),
192                aborted: false,
193            };
194        }
195    };
196
197    // Make the POST request
198    let response = client
199        .post(&hook.url)
200        .headers(headers)
201        .body(json_input.to_string())
202        .send()
203        .await;
204
205    match response {
206        Ok(resp) => {
207            let status = resp.status().as_u16();
208            let body = resp.text().await.unwrap_or_default();
209
210            log_for_debugging(&format!(
211                "Hooks: HTTP hook response status {}, body length {}",
212                status,
213                body.len()
214            ));
215
216            HttpHookResult {
217                ok: status >= 200 && status < 300,
218                status_code: Some(status),
219                body,
220                error: None,
221                aborted: false,
222            }
223        }
224        Err(e) => {
225            if e.is_timeout() {
226                return HttpHookResult {
227                    ok: false,
228                    status_code: None,
229                    body: String::new(),
230                    error: None,
231                    aborted: true,
232                };
233            }
234
235            let error_msg = e.to_string();
236            log_for_debugging(&format!("Hooks: HTTP hook error: {}", error_msg));
237            HttpHookResult {
238                ok: false,
239                status_code: None,
240                body: String::new(),
241                error: Some(error_msg),
242                aborted: false,
243            }
244        }
245    }
246}
247
248/// Check if environment proxy is active (HTTP_PROXY or HTTPS_PROXY set)
249fn is_env_proxy_active() -> bool {
250    std::env::var("HTTP_PROXY").is_ok() || std::env::var("HTTPS_PROXY").is_ok()
251}
252
253/// Check if URL should bypass proxy (respects NO_PROXY)
254fn should_bypass_proxy(url: &str) -> bool {
255    if let Ok(no_proxy) = std::env::var("NO_PROXY") {
256        if let Ok(parsed) = Url::parse(url) {
257            if let Some(host) = parsed.host_str() {
258                for pattern in no_proxy.split(',') {
259                    let pattern = pattern.trim();
260                    if pattern.is_empty() {
261                        continue;
262                    }
263                    // Check if host matches pattern (supports wildcard prefixes)
264                    if pattern.starts_with('.') && host.ends_with(pattern) {
265                        return true;
266                    }
267                    if host == pattern {
268                        return true;
269                    }
270                }
271            }
272        }
273    }
274    false
275}
276
277/// Get sandbox proxy config (simplified)
278async fn get_sandbox_proxy_config() -> Option<ProxyConfig> {
279    // In the TS version, this dynamically imports SandboxManager
280    // and checks if sandboxing is enabled
281    None
282}
283
284struct ProxyConfig {
285    host: String,
286    port: u16,
287    protocol: String,
288}
289
290/// Log for debugging
291fn log_for_debugging(msg: &str) {
292    log::debug!("{}", msg);
293}