953 lines
35 KiB
Rust
953 lines
35 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
/// 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::<IpAddr>() {
|
|
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<std::net::SocketAddr> = 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<u16>, preview: Option<String>| -> 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::<HttpHookOutput>(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::<IpAddr>().unwrap()));
|
|
assert!(is_blocked_ip(
|
|
&"::ffff:192.168.1.1".parse::<IpAddr>().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");
|
|
}
|
|
}
|