//! HTTP hook handler runner. //! //! Executes hooks by POSTing the event envelope JSON to a URL endpoint. //! Supports the same blocking (deny/allow) response format as command hooks. use std::net::IpAddr; use std::time::{Duration, Instant}; use serde::Deserialize; use url::Url; use crate::config::HookSpec; use crate::event::HookEventEnvelope; use crate::result::{HookDecision, HttpInfo}; use super::{HookRunOutput, HookRunnerResult, RunContext}; /// Maximum characters to keep from the response body for the preview. const RESPONSE_PREVIEW_MAX: usize = 200; /// The JSON result structure expected from blocking HTTP hooks. #[derive(Debug, Deserialize)] struct HttpHookOutput { decision: String, #[serde(default)] reason: Option, } /// CWE-918: Returns `true` if an IP address is in a private, link-local, /// or cloud metadata range that should be blocked to prevent SSRF attacks. /// /// Loopback (`127.x` / `::1`) is allowed for local development servers. fn is_blocked_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => { let octets = v4.octets(); if octets[0] == 127 { return false; // loopback — allowed for local dev } if octets[0] == 10 { return true; // RFC 1918: 10.0.0.0/8 } if octets[0] == 172 && (16..=31).contains(&octets[1]) { return true; // RFC 1918: 172.16.0.0/12 } if octets[0] == 192 && octets[1] == 168 { return true; // RFC 1918: 192.168.0.0/16 } if octets[0] == 169 && octets[1] == 254 { return true; // RFC 3927: 169.254.0.0/16 (link-local, cloud metadata) } if octets[0] == 100 && (64..=127).contains(&octets[1]) { return true; // RFC 6598: 100.64.0.0/10 (CGNAT) } if v4.is_unspecified() { return true; // 0.0.0.0 } false } IpAddr::V6(v6) => { if v6.is_loopback() { return false; // ::1 — allowed for local dev } if v6.is_unspecified() { return true; // :: } if let Some(v4) = v6.to_ipv4_mapped() { return is_blocked_ip(&IpAddr::V4(v4)); } let segments = v6.segments(); if segments[0] & 0xffc0 == 0xfe80 { return true; // fe80::/10 — link-local } if segments[0] & 0xfe00 == 0xfc00 { return true; // fc00::/7 — unique local (ULA) } false } } } /// CWE-918: Validate a hook URL to prevent SSRF. /// /// Requirements: /// - Only HTTPS scheme is allowed (reject HTTP / other schemes). /// - Resolved IP addresses must not be in private/link-local/metadata ranges. async fn validate_hook_url(url: &str) -> Result<(), String> { let parsed = Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?; // Restrict to HTTPS only. if parsed.scheme() != "https" { return Err(format!( "only https:// URLs are allowed for HTTP hooks, got {}://", parsed.scheme() )); } let host = parsed .host_str() .ok_or_else(|| "URL has no host".to_string())?; // If host is a literal IP, check it directly. if let Ok(ip) = host.parse::() { if is_blocked_ip(&ip) { return Err(format!("URL resolves to blocked private/internal IP: {ip}")); } return Ok(()); } // DNS resolution check. let port = parsed.port_or_known_default().unwrap_or(443); let addr_str = format!("{host}:{port}"); let addrs: Vec = tokio::net::lookup_host(&addr_str) .await .map_err(|e| format!("DNS resolution failed for {host}: {e}"))? .collect(); if addrs.is_empty() { return Err(format!("DNS resolved no addresses for {host}")); } for addr in &addrs { if is_blocked_ip(&addr.ip()) { return Err(format!( "URL host {host} resolves to blocked private/internal IP: {}", addr.ip() )); } } Ok(()) } /// Run a single HTTP hook. /// /// POSTs the serialized `HookEventEnvelope` as JSON to `spec.url`. /// For blocking hooks (`PreToolUse`), parses the response JSON for /// `{"decision": "allow"}` or `{"decision": "deny", "reason": "..."}`. /// For non-blocking hooks, any 2xx response is success. /// /// Respects `spec.timeout_ms` for the entire request. pub async fn run_http_hook( spec: &HookSpec, envelope: &HookEventEnvelope, _ctx: &RunContext<'_>, is_blocking: bool, ) -> HookRunOutput { let start = Instant::now(); let Some(ref raw_url) = spec.url else { return ( HookRunnerResult::Failed("http hook has no 'url' field".into()), start.elapsed(), None, ); }; // Expand `${VAR}` / `$VAR` in the URL right before validation. We // re-run expansion here (in addition to the load-time pass in // `parse_hook_file`) because plugin URLs can reference plugin-injected // vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) that only land in // `spec.extra_env` after the plugin adapter wires them in. // // For plugin hooks specifically: the load-time pass in // `parse_hook_file` runs BEFORE the plugin adapter populates // `extra_env` with plugin keys, so `${CLAUDE_PLUGIN_ROOT}` etc. // survive that pass and are resolved here at runtime. For // non-plugin hooks the load-time pass already resolved everything // resolvable, and this pass is effectively a no-op. // // Unset refs are preserved verbatim, so `validate_hook_url` will // reject them with an "invalid URL" error rather than silently // smuggling a literal `${VAR}` past validation. let expanded_url = crate::env_expand::expand_env_vars_with_extra(raw_url, &spec.extra_env); let url: &str = &expanded_url; // For tracing/log purposes prefer the pre-expansion source so // resolved values from the user `env` map (which may contain // secrets like API tokens) don't land in `~/.grok/logs`. Falls // back to the expanded form if the spec was constructed by a // legacy path that didn't populate `url_raw`. The same `log_url` // is also threaded into `format!("HTTP request failed for {}: // {}", log_url, e.without_url())` below so reqwest's default // `Display` (which appends the request URL) does not bypass the // raw-source preference. let log_url: &str = spec.url_raw.as_deref().unwrap_or(url); // Helper: build an `HttpInfo` populated with both the // post-expansion `url` (for SSRF debugging) and the raw source // form (for any user-facing display surface). See `HttpInfo` // rustdoc on `crate::result::HttpInfo` for the contract. let make_info = |status: Option, preview: Option| -> HttpInfo { HttpInfo { url: url.to_owned(), raw_url: spec.url_raw.clone(), status, response_preview: preview, } }; // CWE-918: Validate URL before sending any data. if let Err(reason) = validate_hook_url(url).await { tracing::warn!( hook_name = %spec.name, url = %log_url, %reason, "SSRF protection: blocked HTTP hook URL" ); return ( HookRunnerResult::Failed(format!("blocked by SSRF protection: {reason}")), start.elapsed(), Some(make_info(None, None)), ); } let body = match serde_json::to_string(envelope) { Ok(j) => j, Err(e) => { return ( HookRunnerResult::Failed(format!("failed to serialize envelope: {e}")), start.elapsed(), Some(make_info(None, None)), ); } }; let client = reqwest::Client::builder() .timeout(Duration::from_millis(spec.timeout_ms)) .build() .unwrap_or_default(); let response = match client .post(url) .header("Content-Type", "application/json") .body(body) .send() .await { Ok(r) => r, Err(e) => { let elapsed = start.elapsed(); // SECURITY: `reqwest::Error::Display` unconditionally // appends the request URL. If the resolved URL embeds a // secret resolved from the user `env` map (e.g. // `?token=ghp_REAL_SECRET`), the secret would otherwise // land in `HookRunResult::Failed.error` and surface in // pager scrollback / wire DTOs. `e.without_url()` strips // the URL from the formatted output so we substitute our // own `log_url` (which prefers the raw source form) in // its place. let error = if e.is_timeout() { format!("timed out after {}ms", spec.timeout_ms) } else { format!("HTTP request failed for {}: {}", log_url, e.without_url()) }; return ( HookRunnerResult::Failed(error), elapsed, Some(make_info(None, None)), ); } }; let status = response.status(); let status_code = status.as_u16(); let elapsed = start.elapsed(); tracing::debug!( hook_name = %spec.name, url = %log_url, status = status_code, elapsed_ms = elapsed.as_millis() as u64, "http hook completed" ); if !is_blocking { let http_info = Some(make_info(Some(status_code), None)); if status.is_success() { return (HookRunnerResult::Success, elapsed, http_info); } return ( HookRunnerResult::Failed(format!("HTTP status {}", status)), elapsed, http_info, ); } // Blocking hook: parse response JSON for decision. let response_text = match response.text().await { Ok(t) => t, Err(e) => { // SECURITY: same `without_url()` reasoning as the send // failure above -- reqwest's body-read error also includes // the URL by default. return ( HookRunnerResult::Failed(format!( "failed to read response body for {}: {}", log_url, e.without_url() )), elapsed, Some(make_info(Some(status_code), None)), ); } }; let response_preview = if response_text.trim().is_empty() { None } else { Some(truncate_preview(&response_text)) }; let http_info = Some(make_info(Some(status_code), response_preview.clone())); let result = parse_http_blocking_result(&response_text, status, &spec.name); (result, elapsed, http_info) } /// Parse an HTTP blocking hook response into a `HookRunnerResult`. /// /// This is the HTTP analogue of `command::parse_blocking_result`. /// Extracted as a standalone function so it can be unit-tested without /// making real HTTP requests. fn parse_http_blocking_result( response_text: &str, status: reqwest::StatusCode, hook_name: &str, ) -> HookRunnerResult { if response_text.trim().is_empty() { // No body: use HTTP status as fallback. if status.is_success() { return HookRunnerResult::Decision(HookDecision::Allow); } return HookRunnerResult::Failed(format!("HTTP status {} with empty body", status)); } match serde_json::from_str::(response_text) { Ok(output) => { if output.decision == "deny" { let reason = output .reason .unwrap_or_else(|| format!("denied by hook '{}'", hook_name)); HookRunnerResult::Decision(HookDecision::Deny { reason, hook_name: hook_name.to_string(), }) } else if output.decision == "allow" { HookRunnerResult::Decision(HookDecision::Allow) } else { HookRunnerResult::Failed(format!( "unknown decision value '{}' from hook '{}'", output.decision, hook_name )) } } Err(e) => { // Cannot parse response: fail-open if status is success. if status.is_success() { tracing::warn!( hook_name = %hook_name, error = %e, "could not parse HTTP hook response JSON, treating as allow" ); HookRunnerResult::Decision(HookDecision::Allow) } else { HookRunnerResult::Failed(format!( "HTTP status {} and failed to parse response: {e}", status )) } } } } /// Truncate a response body string for preview display. /// /// Uses `char_indices` to find a safe UTF-8 boundary so we never panic /// on multi-byte characters. fn truncate_preview(s: &str) -> String { let trimmed = s.trim(); if trimmed.len() <= RESPONSE_PREVIEW_MAX { trimmed.to_string() } else { // Find the last char boundary at or before RESPONSE_PREVIEW_MAX bytes. let boundary = trimmed .char_indices() .take_while(|&(i, _)| i <= RESPONSE_PREVIEW_MAX) .last() .map(|(i, _)| i) .unwrap_or(0); let mut preview = trimmed[..boundary].to_string(); preview.push_str("..."); preview } } #[cfg(test)] mod tests { use super::*; use reqwest::StatusCode; // ── parse_http_blocking_result tests ────────────────────────── #[test] fn http_allow_json() { let result = parse_http_blocking_result(r#"{"decision":"allow"}"#, StatusCode::OK, "test-hook"); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_deny_json_with_reason() { let result = parse_http_blocking_result( r#"{"decision":"deny","reason":"dangerous command"}"#, StatusCode::OK, "test-hook", ); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, hook_name }) => { assert_eq!(reason, "dangerous command"); assert_eq!(hook_name, "test-hook"); } other => panic!("expected Deny, got {other:?}"), } } #[test] fn http_deny_json_without_reason() { let result = parse_http_blocking_result(r#"{"decision":"deny"}"#, StatusCode::OK, "my-hook"); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert!( reason.contains("my-hook"), "reason should mention hook name" ); } other => panic!("expected Deny, got {other:?}"), } } #[test] fn http_unknown_decision_fails() { let result = parse_http_blocking_result(r#"{"decision":"maybe"}"#, StatusCode::OK, "test-hook"); match result { HookRunnerResult::Failed(msg) => { assert!(msg.contains("maybe")); assert!(msg.contains("test-hook")); } other => panic!("expected Failed, got {other:?}"), } } #[test] fn http_empty_body_success_allows() { let result = parse_http_blocking_result("", StatusCode::OK, "test-hook"); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_empty_body_whitespace_success_allows() { let result = parse_http_blocking_result(" \n ", StatusCode::OK, "test-hook"); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_empty_body_error_status_fails() { let result = parse_http_blocking_result("", StatusCode::INTERNAL_SERVER_ERROR, "test-hook"); match result { HookRunnerResult::Failed(msg) => { assert!(msg.contains("500")); assert!(msg.contains("empty body")); } other => panic!("expected Failed, got {other:?}"), } } #[test] fn http_invalid_json_success_status_fail_open() { // Unparseable JSON with 200 OK should fail-open to allow. let result = parse_http_blocking_result("not json at all", StatusCode::OK, "test-hook"); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_invalid_json_error_status_fails() { // Unparseable JSON with 500 should fail. let result = parse_http_blocking_result("not json", StatusCode::INTERNAL_SERVER_ERROR, "test-hook"); match result { HookRunnerResult::Failed(msg) => { assert!(msg.contains("500")); } other => panic!("expected Failed, got {other:?}"), } } #[test] fn http_deny_with_non_success_status() { let result = parse_http_blocking_result( r#"{"decision":"deny","reason":"forbidden"}"#, StatusCode::FORBIDDEN, "test-hook", ); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert_eq!(reason, "forbidden"); } other => panic!("expected Deny, got {other:?}"), } } #[test] fn http_allow_with_non_success_status() { let result = parse_http_blocking_result( r#"{"decision":"allow"}"#, StatusCode::BAD_REQUEST, "test-hook", ); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_partial_json_success_fail_open() { let result = parse_http_blocking_result(r#"{"decision":"deny""#, StatusCode::OK, "test-hook"); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn http_extra_fields_tolerated() { let result = parse_http_blocking_result( r#"{"decision":"deny","reason":"nope","extra":"ignored","count":42}"#, StatusCode::OK, "test-hook", ); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert_eq!(reason, "nope"); } other => panic!("expected Deny, got {other:?}"), } } // ── SSRF protection: is_blocked_ip tests ────────────── #[test] fn ssrf_blocks_rfc1918_10x() { assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap())); assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap())); } #[test] fn ssrf_blocks_rfc1918_172x() { assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap())); assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap())); assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap())); assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap())); } #[test] fn ssrf_blocks_rfc1918_192168() { assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap())); assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap())); } #[test] fn ssrf_blocks_link_local_metadata() { assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap())); assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap())); } #[test] fn ssrf_blocks_cgnat() { assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap())); assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap())); assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap())); } #[test] fn ssrf_blocks_unspecified() { assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap())); assert!(is_blocked_ip(&"::".parse().unwrap())); } #[test] fn ssrf_allows_loopback() { assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap())); assert!(!is_blocked_ip(&"::1".parse().unwrap())); } #[test] fn ssrf_allows_public_ips() { assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap())); assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap())); } #[test] fn ssrf_blocks_ipv6_link_local() { assert!(is_blocked_ip(&"fe80::1".parse().unwrap())); } #[test] fn ssrf_blocks_ipv6_unique_local() { assert!(is_blocked_ip(&"fc00::1".parse().unwrap())); assert!(is_blocked_ip(&"fd00::1".parse().unwrap())); } #[test] fn ssrf_blocks_ipv4_mapped_ipv6_private() { assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::().unwrap())); assert!(is_blocked_ip( &"::ffff:192.168.1.1".parse::().unwrap() )); } // ── SSRF protection: validate_hook_url tests ────────── #[tokio::test] async fn ssrf_rejects_http_scheme() { let result = validate_hook_url("http://example.com/hook").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("https://")); } #[tokio::test] async fn ssrf_rejects_ftp_scheme() { let result = validate_hook_url("ftp://example.com/hook").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("https://")); } #[tokio::test] async fn ssrf_rejects_private_ip_literal() { let result = validate_hook_url("https://10.0.0.1/hook").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("blocked")); } #[tokio::test] async fn ssrf_rejects_metadata_ip_literal() { let result = validate_hook_url("https://169.254.169.254/latest/meta-data/").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("blocked")); } #[tokio::test] async fn ssrf_allows_https_public_ip() { let result = validate_hook_url("https://1.1.1.1/hook").await; assert!(result.is_ok()); } #[tokio::test] async fn ssrf_rejects_invalid_url() { let result = validate_hook_url("not a url").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("invalid URL")); } // ── URL env-var expansion (extra_env precedence) ─────────── use crate::config::HookSpec; use crate::event::{HookEventEnvelope, HookEventName, HookPayload}; use crate::test_support::with_env_var; /// Regression: an HTTP hook whose `url` references a var present only /// in `spec.extra_env` (not the process env) must still be expanded /// at runtime by `run_http_hook`. This is the path used by plugin /// hooks where the plugin adapter wires `${CLAUDE_PLUGIN_ROOT}` into /// `extra_env` after the load-time pass in `parse_hook_file` ran. /// /// Documentation-of-intent unit test for the helper. The end-to-end /// proof through `run_http_hook` lives in /// [`run_http_hook_uses_post_expansion_url_for_ssrf`] below. #[test] fn url_extra_env_takes_precedence_in_runtime_expansion() { // Use the same helper the runtime path uses; we don't need to // make a real network call to verify the substitution, only that // the helper resolves the right value from extra_env. let mut extra = std::collections::HashMap::new(); extra.insert("PLUGIN_HOST".to_string(), "example.com".to_string()); let out = crate::env_expand::expand_env_vars_with_extra("https://${PLUGIN_HOST}/check", &extra); assert_eq!(out, "https://example.com/check"); } /// If `extra_env` shadows a process-env var with the same name, the /// `extra_env` value wins. This matches the contract documented on /// `HookSpec::extra_env` and matches the lookup order in /// `runner/command.rs`'s pre-flight check. Documentation-of-intent /// unit test (the end-to-end variant via `run_http_hook` lives in /// `tests/integration.rs`). #[test] fn url_extra_env_shadows_process_env() { let key = "GROK_HOOKS_HTTP_TEST_SHADOW"; with_env_var(key, Some("from-process"), || { let mut extra = std::collections::HashMap::new(); extra.insert(key.to_string(), "from-extra".to_string()); let out = crate::env_expand::expand_env_vars_with_extra( &format!("https://${{{key}}}/x"), &extra, ); assert_eq!(out, "https://from-extra/x"); }); } /// Regression: a URL with multiple `${VAR}` references must /// expand all of them. Locks down behaviour against shellexpand /// regressions that affect consecutive references. #[test] fn url_with_multiple_consecutive_env_refs_expands_all() { let mut extra = std::collections::HashMap::new(); extra.insert("HOST".to_string(), "api.example.com".to_string()); extra.insert("PORT".to_string(), "8443".to_string()); extra.insert("ROUTE".to_string(), "v2/check".to_string()); let out = crate::env_expand::expand_env_vars_with_extra( "https://${HOST}:${PORT}/${ROUTE}", &extra, ); assert_eq!(out, "https://api.example.com:8443/v2/check"); } /// Regression: SSRF validation in `run_http_hook` must /// operate on the POST-expansion URL. We construct a `HookSpec` /// with `url: "https://${INTERNAL}/hook"` and `extra_env` mapping /// `INTERNAL=10.0.0.1`, then call `run_http_hook` directly and /// assert the failure carries SSRF-blocking language and that the /// `HttpInfo.url` returned for scrollback is the post-expansion /// form (`10.0.0.1`) rather than the literal placeholder. #[tokio::test] async fn run_http_hook_uses_post_expansion_url_for_ssrf() { let mut extra_env = std::collections::HashMap::new(); extra_env.insert("INTERNAL_HOST_SSRF".to_string(), "10.0.0.1".to_string()); let raw = "https://${INTERNAL_HOST_SSRF}/hook"; let spec = HookSpec { name: "test-ssrf-post-expand".into(), event: HookEventName::PreToolUse, handler_type: "http".into(), configured_matcher: None, matcher: None, enabled: true, command: None, command_raw: None, url: Some(raw.to_string()), url_raw: Some(raw.to_string()), timeout_ms: 1000, source_dir: std::env::temp_dir(), extra_env, }; let envelope = HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, session_id: "test".into(), cwd: "/tmp".into(), workspace_root: "/tmp".into(), timestamp: "2025-01-01T00:00:00Z".into(), transcript_path: None, client_identifier: None, prompt_id: None, payload: HookPayload::PreToolUse { tool_name: "test".into(), tool_use_id: "id-1".into(), tool_input: serde_json::json!({}), tool_input_truncated: false, permission_mode: None, subagent_type: None, }, }; let ctx = crate::runner::RunContext { session_id: "test", workspace_root: "/tmp", }; let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await; match result { crate::runner::HookRunnerResult::Failed(reason) => { assert!( reason.contains("blocked") || reason.contains("SSRF"), "expected SSRF block message, got: {reason}" ); } other => panic!("expected SSRF Failed, got {other:?}"), } let info = info.expect("HttpInfo should be present for SSRF block path"); assert_eq!( info.url, "https://10.0.0.1/hook", "HttpInfo.url must reflect the post-expansion URL (the actual target SSRF blocked)" ); // HttpInfo.raw_url must mirror the source // form so any future scrollback/wire-DTO consumer can prefer // it for user-facing display. assert_eq!( info.raw_url.as_deref(), Some("https://${INTERNAL_HOST_SSRF}/hook"), "HttpInfo.raw_url must mirror HookSpec::url_raw" ); } /// Regression: `reqwest::Error::Display` /// unconditionally appends the request URL. If the resolved URL /// embeds a secret resolved via `${TOKEN}` substitution from the /// user `env` map, the secret would land in /// `HookRunResult::Failed.error` and surface in pager scrollback /// without the raw-fields work catching it. This test /// builds a HookSpec that resolves to a guaranteed-dead host /// (TEST-NET-1 192.0.2.0/24 from RFC 5737, used in docs) with a /// secret-bearing query string, calls run_http_hook, and asserts /// the secret does NOT appear in the returned error message. #[tokio::test] async fn run_http_hook_scrubs_url_from_reqwest_error() { // Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in // public networks"). It is not RFC1918 so SSRF validation // will let it through, but no real DNS or connection will // succeed -- reqwest will surface a connection error whose // default Display includes the URL. let secret = "ghp_VERY_REAL_SECRET_TOKEN_42"; let mut extra_env = std::collections::HashMap::new(); extra_env.insert("RUNTIME_HOST".to_string(), "192.0.2.1".to_string()); extra_env.insert("MY_TOKEN".to_string(), secret.to_string()); let raw = "https://${RUNTIME_HOST}/check?token=${MY_TOKEN}"; let spec = HookSpec { name: "test-scrub-reqwest-error".into(), event: HookEventName::PreToolUse, handler_type: "http".into(), configured_matcher: None, matcher: None, enabled: true, command: None, command_raw: None, url: Some(raw.to_string()), url_raw: Some(raw.to_string()), // Short timeout so the test doesn't hang waiting for the // dead host. Still long enough to actually attempt the // connection so we exercise the Err(e) branch of `send().await`. timeout_ms: 500, source_dir: std::env::temp_dir(), extra_env, }; let envelope = HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, session_id: "test".into(), cwd: "/tmp".into(), workspace_root: "/tmp".into(), timestamp: "2025-01-01T00:00:00Z".into(), transcript_path: None, client_identifier: None, prompt_id: None, payload: HookPayload::PreToolUse { tool_name: "test".into(), tool_use_id: "id-1".into(), tool_input: serde_json::json!({}), tool_input_truncated: false, permission_mode: None, subagent_type: None, }, }; let ctx = crate::runner::RunContext { session_id: "test", workspace_root: "/tmp", }; let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await; // Either `Failed` (timeout / connection error) is fine; both // exercise paths that previously embedded the raw URL via // `format!("...{e}")`. Pure timeouts use a different // formatting branch (no URL involved), so prefer the // connection-error case but tolerate either. let error_text = match result { crate::runner::HookRunnerResult::Failed(reason) => reason, other => panic!("expected Failed, got {other:?}"), }; // The secret must NOT be in the error text. This covers BOTH // the timeout branch (which doesn't format the URL at all, // so trivially passes) and the connection-error branch // (which formats `e.without_url()`, scrubbing the URL). assert!( !error_text.contains(secret), "secret leaked into error text: {error_text}" ); // The error must mention the raw URL form (so users can see // which hook failed) -- never the resolved form, which would // include the secret-bearing query string. if !error_text.contains("timed out") { // Connection-error branch: error must reference the raw // form, not the resolved one. assert!( error_text.contains("${RUNTIME_HOST}") || error_text.contains("${MY_TOKEN}"), "expected error to reference the raw URL form, got: {error_text}" ); } // HttpInfo.url is still post-expansion (intentional, for SSRF // debugging). The wire-DTO consumer must prefer raw_url for // display -- documented in the HttpInfo rustdoc. let info = info.expect("HttpInfo should be present for connection failures too"); assert_eq!( info.url, "https://192.0.2.1/check?token=ghp_VERY_REAL_SECRET_TOKEN_42" ); assert_eq!(info.raw_url.as_deref(), Some(raw)); } /// Unresolved `${VAR}` refs are preserved verbatim by the helper, /// which means `validate_hook_url` will reject the URL with an /// "invalid URL" error. This is the desired behaviour: a hook /// referencing an unset var must surface a clear failure rather than /// silently smuggling the literal placeholder past validation. #[tokio::test] async fn url_unresolved_var_fails_validation() { let key = "GROK_HOOKS_HTTP_TEST_UNRESOLVED"; // Step 1 (sync): ensure the var is unset and run the // expansion. `with_env_var` uses `catch_unwind` so the closure // is synchronous; we deliberately do the async `validate_hook_url` // call OUTSIDE the helper so we don't try to nest tokio runtimes. let expanded = with_env_var(key, None, || { let extra = std::collections::HashMap::new(); crate::env_expand::expand_env_vars_with_extra( &format!("https://${{{key}}}/check"), &extra, ) }); // The literal placeholder is preserved. assert!(expanded.contains(&format!("${{{key}}}"))); // Url::parse rejects strings with literal `${` because `{` // isn't a valid URL character. let result = validate_hook_url(&expanded).await; assert!(result.is_err(), "expected invalid URL error, got Ok"); } }