ai_agent/utils/hooks/
exec_http_hook.rs1#![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
13const DEFAULT_HTTP_HOOK_TIMEOUT_MS: u64 = 10 * 60 * 1000;
15
16pub struct HttpHook {
18 pub url: String,
20 pub timeout: Option<u64>,
22 pub headers: Option<std::collections::HashMap<String, String>>,
24 pub allowed_env_vars: Option<Vec<String>>,
26}
27
28pub 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
37struct HttpHookPolicy {
39 allowed_urls: Option<Vec<String>>,
40 allowed_env_vars: Option<Vec<String>>,
41}
42
43fn get_http_hook_policy() -> HttpHookPolicy {
45 HttpHookPolicy {
47 allowed_urls: None, allowed_env_vars: None,
49 }
50}
51
52fn url_matches_pattern(url: &str, pattern: &str) -> bool {
54 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
63fn sanitize_header_value(value: &str) -> String {
65 value.replace(|c: char| c == '\r' || c == '\n' || c == '\0', "")
66}
67
68fn 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: ®ex::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 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
94pub async fn exec_http_hook(
96 hook: &HttpHook,
97 _hook_event: &str,
98 json_input: &str,
99) -> HttpHookResult {
100 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 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 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 let mut client_builder = Client::builder().timeout(Duration::from_millis(timeout_ms));
159
160 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 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 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
248fn is_env_proxy_active() -> bool {
250 std::env::var("HTTP_PROXY").is_ok() || std::env::var("HTTPS_PROXY").is_ok()
251}
252
253fn 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 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
277async fn get_sandbox_proxy_config() -> Option<ProxyConfig> {
279 None
282}
283
284struct ProxyConfig {
285 host: String,
286 port: u16,
287 protocol: String,
288}
289
290fn log_for_debugging(msg: &str) {
292 log::debug!("{}", msg);
293}