use std::{collections::HashMap, env, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use rmcp::ErrorData;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::time;
use void_crawl_core::{
CapturedResponse, LeaseScope, ResponseBodyState, ResponseCaptureLimits, VoidCrawlError,
fork_scoped,
};
use crate::{errors::map_err, server::VoidCrawlServer, sessions::PendingNetworkCapture};
const DEFAULT_WAIT_SECS: u64 = 30;
const DEFAULT_ARM_CEILING_SECS: u64 = 300;
pub const ENABLE_ENV: &str = "VOIDCRAWL_ALLOW_CREDENTIAL_CAPTURE";
const SENSITIVE_HEADER_SUBSTRINGS: &[&str] = &[
"authorization",
"authenticate",
"authentication",
"x-auth",
"cookie",
"token",
"api-key",
"apikey",
"secret",
"credential",
"password",
"signature",
"session",
];
const REDACTED: &str = "<redacted>";
fn is_sensitive_header(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
SENSITIVE_HEADER_SUBSTRINGS.iter().any(|needle| lower.contains(needle))
}
fn raw_access_enabled() -> bool {
enabled_from(env::var(ENABLE_ENV).ok().as_deref())
}
fn enabled_from(value: Option<&str>) -> bool {
match value {
Some(v) => {
let v = v.trim();
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
}
None => false,
}
}
fn raw_access_denied_err(what: &str) -> VoidCrawlError {
VoidCrawlError::Other(format!(
"{what} exposes raw credential values and is disabled; set {ENABLE_ENV}=1 to enable"
))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NetworkPatternArg {
pub name: String,
pub url_glob: String,
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct NetworkCaptureArmArgs {
pub session_id: String,
pub patterns: Vec<NetworkPatternArg>,
#[serde(default)]
pub arm_ceiling_secs: Option<u64>,
#[serde(default)]
pub capture_body: bool,
#[serde(default)]
pub max_response_bytes: Option<usize>,
#[serde(default)]
pub max_total_bytes: Option<usize>,
#[serde(default)]
pub include_sensitive_headers: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct NetworkCaptureArmResult {
pub armed: bool,
pub message: String,
}
pub async fn arm(
server: &VoidCrawlServer,
args: NetworkCaptureArmArgs,
) -> Result<NetworkCaptureArmResult, ErrorData> {
if args.include_sensitive_headers && !raw_access_enabled() {
return Err(map_err(raw_access_denied_err("include_sensitive_headers")));
}
if args.patterns.is_empty() {
return Err(map_err(VoidCrawlError::Other("at least one pattern is required".into())));
}
let session = server
.state()
.sessions
.get(&args.session_id)
.await
.ok_or_else(|| VoidCrawlError::Other(format!("no such session: {}", args.session_id)))
.map_err(map_err)?;
let mut slot = session.pending_network_capture.lock().await;
if slot.is_some() {
return Err(map_err(VoidCrawlError::Other(
"a network capture is already armed on this session; call network_capture_wait first"
.into(),
)));
}
let patterns = args.patterns.into_iter().map(|p| (p.name, p.url_glob)).collect::<Vec<_>>();
let ceiling = Duration::from_secs(args.arm_ceiling_secs.unwrap_or(DEFAULT_ARM_CEILING_SECS));
let limits = ResponseCaptureLimits {
max_response_bytes: args
.max_response_bytes
.unwrap_or(void_crawl_core::DEFAULT_MAX_RESPONSE_BYTES),
max_total_bytes: args
.max_total_bytes
.unwrap_or(void_crawl_core::DEFAULT_MAX_TOTAL_RESPONSE_BYTES),
};
let capture = {
let page = session.page.lock().await;
page.expect_responses(patterns, ceiling, limits).await.map_err(map_err)?
};
*slot = Some(PendingNetworkCapture {
capture,
include_sensitive_headers: args.include_sensitive_headers,
capture_body: args.capture_body,
});
Ok(NetworkCaptureArmResult {
armed: true,
message: "network capture armed — perform the action that triggers the requests, then \
call network_capture_wait"
.into(),
})
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct NetworkCaptureWaitArgs {
pub session_id: String,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CapturedResponseJson {
pub url: String,
pub status: u16,
pub request_headers: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub mime_type: String,
pub resource_type: String,
pub from_cache: bool,
pub from_service_worker: bool,
pub body_state: String,
pub body_error: Option<String>,
pub body_base64: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct NetworkCaptureWaitResult {
pub captures: HashMap<String, CapturedResponseJson>,
}
fn redact_headers(headers: &[(String, String)], include_sensitive: bool) -> Vec<(String, String)> {
headers
.iter()
.map(|(k, v)| {
if !include_sensitive && is_sensitive_header(k) {
(k.clone(), REDACTED.to_string())
} else {
(k.clone(), v.clone())
}
})
.collect()
}
fn to_json(
name_to_response: HashMap<String, CapturedResponse>,
include_sensitive_headers: bool,
capture_body: bool,
) -> HashMap<String, CapturedResponseJson> {
name_to_response
.into_iter()
.map(|(name, resp)| {
let body_base64 = (capture_body
&& resp.body_state != ResponseBodyState::Unavailable
&& !resp.body().is_empty())
.then(|| BASE64.encode(resp.body()));
let json = CapturedResponseJson {
url: resp.url.clone(),
status: resp.status,
request_headers: redact_headers(&resp.request_headers, include_sensitive_headers),
headers: redact_headers(&resp.headers, include_sensitive_headers),
mime_type: resp.mime_type.clone(),
resource_type: resp.resource_type.clone(),
from_cache: resp.from_cache,
from_service_worker: resp.from_service_worker,
body_state: resp.body_state.as_str().to_string(),
body_error: resp.body_error.clone(),
body_base64,
};
(name, json)
})
.collect()
}
pub async fn wait(
server: &VoidCrawlServer,
args: NetworkCaptureWaitArgs,
) -> Result<NetworkCaptureWaitResult, ErrorData> {
let session = server
.state()
.sessions
.get(&args.session_id)
.await
.ok_or_else(|| VoidCrawlError::Other(format!("no such session: {}", args.session_id)))
.map_err(map_err)?;
let pending = session.pending_network_capture.lock().await.take().ok_or_else(|| {
map_err(VoidCrawlError::Other(
"no armed network capture for this session; call network_capture_arm first".into(),
))
})?;
let PendingNetworkCapture { capture, include_sensitive_headers, capture_body } = pending;
let budget = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_WAIT_SECS));
let result = match time::timeout(budget, capture.wait()).await {
Ok(inner) => inner.map_err(map_err)?,
Err(_) => {
return Err(map_err(VoidCrawlError::Timeout(format!(
"no matching responses observed within {}s of network_capture_wait; the armed \
patterns may not match the requests the page actually made",
budget.as_secs()
))));
}
};
Ok(NetworkCaptureWaitResult {
captures: to_json(result, include_sensitive_headers, capture_body),
})
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct SessionCookiesArgs {
pub session_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct SessionCookiesResult {
pub cookies: Vec<serde_json::Value>,
}
pub async fn cookies(
server: &VoidCrawlServer,
args: SessionCookiesArgs,
) -> Result<SessionCookiesResult, ErrorData> {
if !raw_access_enabled() {
return Err(map_err(raw_access_denied_err("session_cookies")));
}
let session = server
.state()
.sessions
.get(&args.session_id)
.await
.ok_or_else(|| VoidCrawlError::Other(format!("no such session: {}", args.session_id)))
.map_err(map_err)?;
let page = session.page.lock().await;
let raw_cookies = page.get_cookies().await.map_err(map_err)?;
let cookies = raw_cookies
.into_iter()
.map(|c| {
serde_json::to_value(c).map_err(|e| {
map_err(VoidCrawlError::Other(format!("failed to serialize cookie: {e}")))
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(SessionCookiesResult { cookies })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_headers_are_detected_case_insensitively() {
for name in [
"authorization",
"Authorization",
"AUTHORIZATION",
"proxy-authorization",
"x-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
"x-apikey",
"x-goog-api-key",
"x-csrf-token",
"x-xsrf-token",
"x-access-token",
"x-refresh-token",
"x-session-token",
"x-amz-security-token",
"x-amz-signature",
"www-authenticate",
"authentication-info",
"x-auth-user",
"x-client-secret",
"x-password",
] {
assert!(is_sensitive_header(name), "{name} should be treated as credential-bearing");
}
}
#[test]
fn ordinary_headers_are_not_redacted() {
for name in [
"content-type",
"content-length",
"accept",
"accept-encoding",
"accept-language",
"date",
"server",
"user-agent",
"referer",
"cache-control",
"etag",
"location",
"origin",
] {
assert!(!is_sensitive_header(name), "{name} should NOT be redacted");
}
}
#[test]
fn redaction_hides_the_value_and_its_length_but_keeps_the_name() {
let headers = vec![
("authorization".to_string(), "Bearer supersecrettoken".to_string()),
("content-type".to_string(), "application/json".to_string()),
];
let out = redact_headers(&headers, false);
assert_eq!(out[0].0, "authorization");
assert_eq!(out[0].1, "<redacted>");
assert!(!out[0].1.contains("22"));
assert_eq!(out[1].1, "application/json");
}
#[test]
fn opting_in_returns_raw_values() {
let headers = vec![("authorization".to_string(), "Bearer tok".to_string())];
assert_eq!(redact_headers(&headers, true)[0].1, "Bearer tok");
}
#[test]
fn raw_access_gate_is_closed_by_default_and_rejects_falsey_values() {
assert!(!enabled_from(None));
assert!(!enabled_from(Some("")));
assert!(!enabled_from(Some(" ")));
assert!(!enabled_from(Some("0")));
assert!(!enabled_from(Some("false")));
assert!(!enabled_from(Some("FALSE")));
assert!(enabled_from(Some("1")));
assert!(enabled_from(Some("true")));
assert!(enabled_from(Some("yes")));
}
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct CookieLeaseOpenArgs {
pub session_id: String,
pub replay_origin: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookieLeaseOpenResult {
pub lease_id: String,
pub replay_origin: String,
pub cookies: Vec<serde_json::Value>,
pub observed_in: Option<String>,
}
pub async fn cookie_lease_open(
server: &VoidCrawlServer,
args: CookieLeaseOpenArgs,
) -> Result<CookieLeaseOpenResult, ErrorData> {
let scope = LeaseScope::from_url(&args.replay_origin).map_err(map_err)?;
let session = server
.state()
.sessions
.get(&args.session_id)
.await
.ok_or_else(|| VoidCrawlError::Other(format!("no such session: {}", args.session_id)))
.map_err(map_err)?;
let (raw_cookies, observed_in) = {
let page = session.page.lock().await;
let cookies = page.get_cookies().await.map_err(map_err)?;
let url = page.url().await.map_err(map_err)?;
(cookies, url)
};
let lease = fork_scoped(&raw_cookies, scope, &args.session_id, observed_in.as_deref());
let lease_id = lease.id().to_string();
let replay_origin = lease.scope().origin();
let cookies = lease
.provenance()
.into_iter()
.map(|p| {
serde_json::to_value(p).map_err(|e| {
map_err(VoidCrawlError::Other(format!("failed to serialize provenance: {e}")))
})
})
.collect::<Result<Vec<_>, _>>()?;
session.cookie_leases.lock().await.insert(lease_id.clone(), lease);
Ok(CookieLeaseOpenResult { lease_id, replay_origin, cookies, observed_in })
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct CookieLeaseRevokeArgs {
pub session_id: String,
pub lease_id: String,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookieLeaseRevokeResult {
pub revoked: bool,
pub reason: String,
}
pub async fn cookie_lease_revoke(
server: &VoidCrawlServer,
args: CookieLeaseRevokeArgs,
) -> Result<CookieLeaseRevokeResult, ErrorData> {
let session = server
.state()
.sessions
.get(&args.session_id)
.await
.ok_or_else(|| VoidCrawlError::Other(format!("no such session: {}", args.session_id)))
.map_err(map_err)?;
let reason = args.reason.unwrap_or_else(|| "explicit_revoke".to_string());
let mut leases = session.cookie_leases.lock().await;
match leases.remove(&args.lease_id) {
Some(mut lease) => {
lease.revoke(reason.clone());
Ok(CookieLeaseRevokeResult { revoked: true, reason })
}
None => Ok(CookieLeaseRevokeResult {
revoked: false,
reason: format!("no such lease {} on this session (already revoked?)", args.lease_id),
}),
}
}