pdftract/crates/pdftract-core/tests/TH-05-ssrf-block.rs
jedarden 862fe9b395 feat(bf-4b7pm): implement temporary storage for benchmark metrics with JSON serialization
Add comprehensive JSON serialization and validation to RawTimingMetrics:

- Add serde::Serialize/Deserialize derives to RawTimingMetrics
- Implement validate() method checking required metrics (runtime, throughput, file counts)
- Add to_json() and from_json() for import/export
- Implement store_temporary() for JSON file storage (benches/results/raw_metrics_<timestamp>.json)
- Integrate validation and temporary storage into benchmark flow
- Add validation for NaN/infinity values in floating-point metrics

Closes bf-4b7pm. Verification: notes/bf-4b7pm.md, commit $(git rev-parse --short HEAD). Tests: PASS (cargo check --bench grep_1000).
2026-07-06 16:43:05 -04:00

2217 lines
77 KiB
Rust

#![cfg(feature = "remote")]
//! TH-05: SSRF protection tests (Phase 1.8).
//!
//! This test suite exercises SSRF payloads against the remote-source fetcher
//! and the MCP extract tool. It asserts that dangerous URLs are refused with
//! the URL_PRIVATE_NETWORK diagnostic.
//!
//! Test categories:
//! - Cloud metadata endpoints (AWS, GCP, Azure, Alibaba)
//! - RFC 1918 private IPv4 ranges
//! - Loopback addresses
//! - Link-local addresses
//! - IPv6 ULA and loopback
//! - Non-https schemes (http, ftp, file)
//!
//! Each payload is tested against:
//! - CLI: `pdftract extract --url <payload>`
//! - MCP: extract tool with URL parameter
//! - Serve: POST /extract with URL
//!
//! With --allow-private-networks set, the same URLs are accepted.
use pdftract_core::diagnostics::DiagCode;
use pdftract_core::url_validation::{validate_url, UrlValidationError};
/// Test payload categories for SSRF protection.
struct TestPayload {
/// The URL to test
url: &'static str,
/// Expected error variant
expected_error: ExpectedError,
/// Description of what this tests
description: &'static str,
}
#[derive(Debug)]
enum ExpectedError {
InvalidScheme,
PrivateNetwork,
DnsFailed,
}
impl ExpectedError {
fn matches(&self, err: &UrlValidationError) -> bool {
match (self, err) {
(ExpectedError::InvalidScheme, UrlValidationError::InvalidScheme(_)) => true,
(ExpectedError::PrivateNetwork, UrlValidationError::PrivateNetwork(_)) => true,
(ExpectedError::DnsFailed, UrlValidationError::DnsFailed(_)) => true,
_ => false,
}
}
}
/// SSRF test payloads covering all dangerous categories.
const SSRF_PAYLOADS: &[TestPayload] = &[
// === Cloud metadata endpoints ===
TestPayload {
url: "https://169.254.169.254/",
expected_error: ExpectedError::PrivateNetwork,
description: "AWS metadata endpoint (169.254.169.254)",
},
TestPayload {
url: "https://169.254.169.254/latest/meta-data/identity-credentials/ec2/security-credentials/ec2-instance",
expected_error: ExpectedError::PrivateNetwork,
description: "AWS metadata endpoint (full path)",
},
TestPayload {
url: "https://metadata.google.internal/",
expected_error: ExpectedError::PrivateNetwork,
description: "GCP metadata endpoint (hostname)",
},
TestPayload {
url: "https://instance-data.google.internal/",
expected_error: ExpectedError::PrivateNetwork,
description: "GCP instance metadata endpoint",
},
TestPayload {
url: "https://168.63.129.16/",
expected_error: ExpectedError::PrivateNetwork,
description: "Azure metadata endpoint (168.63.129.16)",
},
TestPayload {
url: "https://100.100.100.200/",
expected_error: ExpectedError::PrivateNetwork,
description: "Alibaba metadata endpoint (100.100.100.200)",
},
// === RFC 1918 private IPv4 ranges ===
TestPayload {
url: "https://10.0.0.1/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 10.0.0.0/8 (lower bound)",
},
TestPayload {
url: "https://10.255.255.255/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 10.0.0.0/8 (upper bound)",
},
TestPayload {
url: "https://172.16.0.1/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 172.16.0.0/12 (lower bound)",
},
TestPayload {
url: "https://172.31.255.255/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 172.16.0.0/12 (upper bound)",
},
TestPayload {
url: "https://192.168.1.1/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 192.168.0.0/16",
},
TestPayload {
url: "https://192.168.255.255/",
expected_error: ExpectedError::PrivateNetwork,
description: "RFC 1918: 192.168.0.0/16 (upper bound)",
},
// === Loopback addresses ===
TestPayload {
url: "https://127.0.0.1/",
expected_error: ExpectedError::PrivateNetwork,
description: "Loopback: 127.0.0.1",
},
TestPayload {
url: "https://127.0.0.2/",
expected_error: ExpectedError::PrivateNetwork,
description: "Loopback: 127.0.0.2",
},
TestPayload {
url: "https://127.255.255.255/",
expected_error: ExpectedError::PrivateNetwork,
description: "Loopback: 127.255.255.255",
},
// === Link-local addresses ===
TestPayload {
url: "https://169.254.0.1/",
expected_error: ExpectedError::PrivateNetwork,
description: "IPv4 link-local: 169.254.0.1",
},
// === IPv6 ULA ===
TestPayload {
url: "https://[fd00::1]/",
expected_error: ExpectedError::PrivateNetwork, // IPv6 ULA is detected as private
description: "IPv6 ULA: fd00::1",
},
TestPayload {
url: "https://[fc00::1]/",
expected_error: ExpectedError::PrivateNetwork, // IPv6 ULA is detected as private
description: "IPv6 ULA: fc00::1",
},
// === IPv6 loopback ===
TestPayload {
url: "https://[::1]/",
expected_error: ExpectedError::PrivateNetwork,
description: "IPv6 loopback: ::1",
},
// === IPv6 link-local ===
TestPayload {
url: "https://[fe80::1]/",
expected_error: ExpectedError::PrivateNetwork, // IPv6 link-local is detected as private
description: "IPv6 link-local: fe80::1",
},
// === Non-https schemes ===
TestPayload {
url: "http://example.com/",
expected_error: ExpectedError::InvalidScheme,
description: "HTTP scheme (not https)",
},
TestPayload {
url: "ftp://example.com/",
expected_error: ExpectedError::InvalidScheme,
description: "FTP scheme",
},
TestPayload {
url: "file:///etc/passwd",
expected_error: ExpectedError::InvalidScheme,
description: "file:// scheme",
},
];
/// Public URLs that should be accepted (positive test).
const PUBLIC_URLS: &[&str] = &[
"https://example.com/",
"https://www.google.com/",
"https://github.com/",
"https://8.8.8.8/", // Public DNS
"https://1.1.1.1/", // Cloudflare DNS
];
#[test]
fn test_ssrf_protection_blocks_all_dangerous_payloads() {
for payload in SSRF_PAYLOADS {
let result = validate_url(payload.url, false);
assert!(
result.is_err(),
"URL should be rejected: {} ({})",
payload.url,
payload.description
);
let err = result.unwrap_err();
assert!(
payload.expected_error.matches(&err),
"URL '{}' ({}) expected {:?}, got {:?}",
payload.url,
payload.description,
payload.expected_error,
err
);
}
}
#[test]
fn test_allow_private_networks_bypass() {
for payload in SSRF_PAYLOADS {
// Skip scheme validation tests (those should always fail)
if matches!(payload.expected_error, ExpectedError::InvalidScheme) {
continue;
}
// Skip metadata endpoint tests (those should always fail for security)
if payload.description.contains("metadata") {
continue;
}
// With --allow-private-networks, private network URLs are accepted
let result = validate_url(payload.url, true);
match result {
Ok(_) => {
// URL is now accepted
}
Err(UrlValidationError::DnsFailed(_)) => {
// DNS resolution failure is OK in tests (no network)
}
Err(other) => {
panic!(
"URL '{}' ({}) should be accepted with --allow-private-networks, got: {:?}",
payload.url, payload.description, other
);
}
}
}
}
#[test]
fn test_public_urls_are_accepted() {
for url in PUBLIC_URLS {
// Note: These may fail with DnsFailed in offline test environments
let result = validate_url(url, false);
match result {
Ok(_) => {
// URL accepted
}
Err(UrlValidationError::DnsFailed(_)) => {
// OK in offline tests
}
Err(other) => {
panic!("Public URL '{}' should be accepted, got: {:?}", url, other);
}
}
}
}
#[test]
fn test_http_scheme_always_rejected() {
// Even with --allow-private-networks, http:// is rejected
let result = validate_url("http://127.0.0.1/", true);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_file_scheme_always_rejected() {
let result = validate_url("file:///etc/passwd", true);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_ftp_scheme_always_rejected() {
let result = validate_url("ftp://example.com/", true);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_url_with_basic_auth_rejected() {
// URLs with embedded credentials should still be checked by host, not credentials
let result = validate_url("https://user:pass@127.0.0.1/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
}
#[test]
fn test_ipv6_zone_id_detected_as_link_local() {
// IPv6 zone IDs indicate link-local addresses
let result = validate_url("https://[fe80::1%eth0]/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
}
#[test]
fn test_metadata_subdomain_detected() {
// Subdomains of metadata endpoints should also be blocked
let result = validate_url("https://foo.metadata.google.internal/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
}
#[test]
fn test_url_validation_returns_correct_diagnostic_code() {
use pdftract_core::url_validation::validate_url_with_diagnostic;
let result = validate_url_with_diagnostic("https://127.0.0.1/", false);
assert!(result.is_err());
let diag = result.unwrap_err();
assert_eq!(diag.code, DiagCode::RemoteUrlPrivateNetwork);
}
#[test]
fn test_private_ipv4_boundary_addresses() {
// Test addresses just outside the private ranges
let public_addrs = &[
"172.15.255.255", // Just below 172.16.0.0/12
"172.32.0.1", // Just above 172.16.0.0/12
"192.167.255.255", // Just below 192.168.0.0/16
"192.169.0.1", // Just above 192.168.0.0/16
];
for addr in public_addrs {
let url = format!("https://{}/", addr);
let result = validate_url(&url, false);
// These should not be rejected as private network (may fail DNS in tests)
match result {
Ok(_) => {}
Err(UrlValidationError::DnsFailed(_)) => {}
Err(UrlValidationError::PrivateNetwork(msg)) => {
panic!(
"Public address {} should not be rejected as private: {}",
addr, msg
);
}
Err(_) => {}
}
}
}
#[test]
fn test_current_network_range_blocked() {
// 0.0.0.0/8 (current network) should be blocked
let result = validate_url("https://0.0.0.0/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
let result = validate_url("https://0.0.0.8/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
}
// ============================================================================
// MCP JSON-RPC Message Construction Helpers
// ============================================================================
/// Helper module for constructing JSON-RPC MCP tool call messages.
///
/// This module provides type-safe helpers for building MCP tools/call requests,
/// particularly for SSRF testing. It uses the JSON-RPC framing types from
/// pdftract_cli::mcp::framing and provides convenient constructors for
/// common tool call patterns.
///
/// ## Example
///
/// ```rust
/// use mcp_helpers::ToolCallBuilder;
///
/// let request = ToolCallBuilder::extract()
/// .with_url("https://example.com/doc.pdf")
/// .build();
/// ```
#[cfg(feature = "remote")]
#[cfg(test)]
pub mod mcp_helpers {
use std::io::{BufRead, BufReader};
use serde_json::json;
// ============================================================================
// Local JSON-RPC Type Definitions (to avoid cross-crate imports)
// ============================================================================
/// A JSON-RPC request identifier.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Id {
Number(i64),
String(String),
Null,
}
impl serde::Serialize for Id {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Id::Number(n) => n.serialize(serializer),
Id::String(s) => s.serialize(serializer),
Id::Null => serializer.serialize_none(),
}
}
}
/// A JSON-RPC response identifier.
///
/// Can be a number, string, or null (for notifications).
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)]
#[serde(untagged)]
pub enum ResponseId {
Number(i64),
String(String),
Null,
}
/// A JSON-RPC request object.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct Request {
#[serde(rename = "jsonrpc")]
pub jsonrpc_version: String,
pub method: String,
pub params: Option<serde_json::Value>,
pub id: Option<Id>,
}
impl Request {
/// Create a new request with the given method and optional params.
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>, id: Option<Id>) -> Self {
Self {
jsonrpc_version: "2.0".to_string(),
method: method.into(),
params,
id,
}
}
/// Returns true if this is a notification (no id field).
pub fn is_notification(&self) -> bool {
self.id.is_none()
}
/// Get the request ID, or Id::Null for notifications.
pub fn request_id(&self) -> Id {
self.id.clone().unwrap_or(Id::Null)
}
}
// ============================================================================
// Tool Call Builder
// ============================================================================
/// Builder for constructing MCP tools/call JSON-RPC requests.
///
/// Provides a fluent API for building tool call requests with proper
/// JSON-RPC structure and type-safe parameter construction.
pub struct ToolCallBuilder {
tool_name: String,
arguments: serde_json::Map<String, serde_json::Value>,
request_id: Id,
}
impl ToolCallBuilder {
/// Create a new ToolCallBuilder for the specified tool name.
fn new(tool_name: impl Into<String>) -> Self {
Self {
tool_name: tool_name.into(),
arguments: serde_json::Map::new(),
request_id: Id::Number(1),
}
}
/// Create a builder for the "extract" tool.
///
/// This is the primary tool for PDF extraction and accepts URL
/// parameters that must be validated for SSRF protection.
pub fn extract() -> Self {
Self::new("extract")
}
/// Create a builder for the "get_metadata" tool.
///
/// This tool fetches document metadata and also accepts URL parameters.
pub fn get_metadata() -> Self {
Self::new("get_metadata")
}
/// Set the request ID.
///
/// Default is Id::Number(1). This allows customizing the ID for
/// concurrent request tracking.
pub fn with_id(mut self, id: Id) -> Self {
self.request_id = id;
self
}
/// Add a URL parameter to the tool arguments.
///
/// This is the primary parameter for extract and get_metadata tools
/// when fetching remote PDFs.
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.arguments
.insert("path".to_string(), serde_json::Value::String(url.into()));
self
}
/// Add a custom argument to the tool arguments.
///
/// Allows adding arbitrary parameters like "password", "ocr", etc.
pub fn with_argument(
mut self,
key: impl Into<String>,
value: serde_json::Value,
) -> Self {
self.arguments.insert(key.into(), value);
self
}
/// Build the JSON-RPC request.
///
/// Returns a Request object that can be serialized to JSON and sent
/// to the MCP server via stdio or HTTP transport.
pub fn build(self) -> Request {
let params = json!({
"name": self.tool_name,
"arguments": self.arguments
});
Request::new("tools/call", Some(params), Some(self.request_id))
}
/// Build the request and serialize to JSON string.
///
/// Convenience method that combines build() and serde_json::to_string().
pub fn build_json(self) -> String {
let request = self.build();
// Manual serialization to include jsonrpc field
let id_value = match request.id {
Some(Id::Number(n)) => serde_json::json!(n),
Some(Id::String(s)) => serde_json::json!(s),
Some(Id::Null) => serde_json::json!(null),
None => serde_json::json!(null),
};
let output = json!({
"jsonrpc": "2.0",
"method": request.method,
"params": request.params,
"id": id_value
});
serde_json::to_string(&output)
.expect("ToolCallRequest should always be serializable")
}
}
/// Quick helper to create an extract tool call with just a URL.
///
/// This is the most common pattern for SSRF testing.
///
/// # Example
///
/// ```rust
/// let request = extract_call("https://127.0.0.1/");
/// ```
pub fn extract_call(url: impl Into<String>) -> Request {
ToolCallBuilder::extract().with_url(url).build()
}
/// Quick helper to create a get_metadata tool call with just a URL.
pub fn get_metadata_call(url: impl Into<String>) -> Request {
ToolCallBuilder::get_metadata().with_url(url).build()
}
// ============================================================================
// JSON-RPC Response Parsing Helpers
// ============================================================================
/// JSON-RPC 2.0 error object structure.
///
/// Represents the error field in a JSON-RPC response with code, message,
/// and optional data fields.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct JsonRpcError {
/// The error code (negative for server errors in the -32099..-32000 range)
pub code: i64,
/// Human-readable error message
pub message: String,
/// Optional additional error data
pub data: Option<serde_json::Value>,
}
impl JsonRpcError {
/// Check if this error is an SSRF_BLOCKED error.
///
/// Returns true if the error data contains a "code" field with value "SSRF_BLOCKED"
/// or if the error message contains the substring "SSRF_BLOCKED".
///
/// # Example
///
/// ```rust
/// use mcp_helpers::JsonRpcError;
/// use serde_json::json;
///
/// let error = JsonRpcError {
/// code: -32001,
/// message: "SSRF_BLOCKED: URL targets private network".to_string(),
/// data: Some(json!({"code": "SSRF_BLOCKED"})),
/// };
/// assert!(error.is_ssrf_blocked());
///
/// let error2 = JsonRpcError {
/// code: -32601,
/// message: "Method not found".to_string(),
/// data: None,
/// };
/// assert!(!error2.is_ssrf_blocked());
/// ```
pub fn is_ssrf_blocked(&self) -> bool {
// Check if error data contains "code": "SSRF_BLOCKED"
if let Some(data) = &self.data {
if let Some(code) = data.get("code").and_then(|c| c.as_str()) {
if code == "SSRF_BLOCKED" {
return true;
}
}
}
// Check if the error message itself contains SSRF_BLOCKED
if self.message.contains("SSRF_BLOCKED") {
return true;
}
false
}
}
/// A generic JSON-RPC 2.0 response structure.
///
/// A response has either a result field (success) or an error field (failure),
/// never both. The id field must match the request id.
///
/// # Type Parameters
///
/// * `T` - The type of the result field for successful responses. Must implement
/// `serde::Deserialize` to parse from JSON.
///
/// # Example
///
/// ```rust
/// use mcp_helpers::{JsonRpcResponse, JsonRpcError};
///
/// // A successful response with a custom result type
/// let success_json = r#"{"jsonrpc":"2.0","result":{"status":"ok"},"id":1}"#;
/// let response: JsonRpcResponse<serde_json::Value> = serde_json::from_str(success_json).unwrap();
/// assert!(response.is_success());
/// assert_eq!(response.jsonrpc, "2.0");
///
/// // An error response
/// let error_json = r#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"Error"},"id":1}"#;
/// let response: JsonRpcResponse<serde_json::Value> = serde_json::from_str(error_json).unwrap();
/// assert!(response.is_error());
/// ```
#[derive(Debug, Clone, serde::Deserialize)]
pub struct JsonRpcResponse<T> {
/// Must be exactly "2.0"
pub jsonrpc: String,
/// The successful result value (present only on success)
pub result: Option<T>,
/// The error object (present only on failure)
pub error: Option<JsonRpcError>,
/// Request identifier
pub id: ResponseId,
}
impl<T> JsonRpcResponse<T> {
/// Check if this is a successful response (has result field).
pub fn is_success(&self) -> bool {
self.result.is_some()
}
/// Check if this is an error response (has error field).
pub fn is_error(&self) -> bool {
self.error.is_some()
}
/// Get the error object if present.
pub fn get_error(&self) -> Option<&JsonRpcError> {
self.error.as_ref()
}
/// Get the result value if present.
pub fn get_result(&self) -> Option<&T> {
self.result.as_ref()
}
}
/// Check if a JSON-RPC error response contains the SSRF_BLOCKED error code.
///
/// This helper parses the error data field and checks for the SSRF_BLOCKED
/// code that indicates a URL was rejected due to SSRF protection.
///
/// # Arguments
///
/// * `response_json` - The JSON string of the response to parse
///
/// # Returns
///
/// * `Ok(true)` if the response is an error with SSRF_BLOCKED code
/// * `Ok(false)` if the response is not an SSRF_BLOCKED error
/// * `Err(_)` if the JSON is invalid or not a valid JSON-RPC response
///
/// # Example
///
/// ```rust
/// use mcp_helpers::is_ssrf_blocked_error;
///
/// let error_json = r#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"SSRF_BLOCKED","data":{"code":"SSRF_BLOCKED"}},"id":1}"#;
/// assert!(is_ssrf_blocked_error(error_json).unwrap());
/// ```
pub fn is_ssrf_blocked_error(response_json: &str) -> Result<bool, String> {
// Parse the JSON-RPC response
let parsed: JsonRpcResponse<serde_json::Value> =
serde_json::from_str(response_json).map_err(|e| format!("Invalid JSON: {}", e))?;
// Check if it has an error field
let error = parsed
.error
.ok_or_else(|| "Response has no error field".to_string())?;
// Check the error data for SSRF_BLOCKED code
if let Some(data) = &error.data {
if let Some(code) = data.get("code").and_then(|c| c.as_str()) {
return Ok(code == "SSRF_BLOCKED");
}
}
// Check if the error message itself contains SSRF_BLOCKED
if error.message.contains("SSRF_BLOCKED") {
return Ok(true);
}
Ok(false)
}
/// Extract error information from a JSON-RPC error response.
///
/// Returns a tuple of (code, message, optional_data) for easier error handling.
///
/// # Arguments
///
/// * `response_json` - The JSON string of the error response
///
/// # Returns
///
/// * `Ok((code, message, data))` with the error details
/// * `Err(_)` if parsing fails or response is not an error
pub fn extract_error_info(
response_json: &str,
) -> Result<(i64, String, Option<serde_json::Value>), String> {
let parsed: JsonRpcResponse<serde_json::Value> =
serde_json::from_str(response_json).map_err(|e| format!("Failed to parse response: {}", e))?;
let error = parsed
.error
.ok_or_else(|| "Response is not an error".to_string())?;
Ok((error.code, error.message, error.data))
}
/// Parse a JSON-RPC response string into a JsonRpcResponse object.
///
/// This is a convenience wrapper around serde_json::from_str with better
/// error messages for JSON-RPC specific issues.
///
/// # Arguments
///
/// * `response_json` - The JSON string of the response
///
/// # Returns
///
/// * `Ok(JsonRpcResponse)` if parsing succeeds
/// * `Err(String)` if parsing fails with descriptive error message
pub fn parse_response(response_json: &str) -> Result<JsonRpcResponse<serde_json::Value>, String> {
serde_json::from_str(response_json)
.map_err(|e| format!("Failed to parse JSON-RPC response: {}", e))
}
// ============================================================================
// JSON-RPC Framing Helpers
// ============================================================================
/// Read a framed JSON-RPC response from a reader.
///
/// Parses the Content-Length header, reads the exact number of bytes,
/// and returns the JSON body as a string.
///
/// # Arguments
///
/// * `reader` - Buffered reader to read from (typically BufReader<ChildStdout>)
///
/// # Returns
///
/// * `Ok(Some(json_body))` - Successfully read and parsed the frame
/// * `Ok(None)` - Reached EOF
/// * `Err(_)` - I/O error or invalid framing
fn read_framed_response<R: std::io::Read>(
reader: &mut BufReader<R>,
) -> std::io::Result<Option<String>> {
let mut content_length: Option<usize> = None;
// Read headers until empty line
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
return Ok(None); // EOF
}
let line = line.trim_end_matches(|c| c == '\r' || c == '\n');
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = Some(
value
.trim()
.parse::<usize>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
);
}
}
let content_length = content_length.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Missing Content-Length header",
)
})?;
let mut buffer = vec![0u8; content_length];
reader.read_exact(&mut buffer)?;
Ok(Some(String::from_utf8(buffer).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?))
}
/// Write a framed JSON-RPC message to a writer.
///
/// Uses LSP-style framing: Content-Length header, blank line, then JSON body.
///
/// # Arguments
///
/// * `writer` - Writer to send the framed message to
/// * `json_body` - The JSON body string to send
fn write_framed_message<W: std::io::Write>(
writer: &mut W,
json_body: &str,
) -> std::io::Result<()> {
let header = format!("Content-Length: {}\r\n\r\n", json_body.len());
writer.write_all(header.as_bytes())?;
writer.write_all(json_body.as_bytes())?;
writer.flush()
}
// ============================================================================
// High-Level MCP Tool Call Helper
// ============================================================================
/// Result type for MCP tool call responses.
///
/// Provides a structured Result that distinguishes between successful
/// tool call results and JSON-RPC errors, making it easy to assert
/// expected behavior in tests.
#[derive(Debug, Clone)]
pub enum ToolCallResult {
/// Successful tool call with the result data
Success(serde_json::Value),
/// Error response with error code, message, and optional data
Error {
code: i64,
message: String,
data: Option<serde_json::Value>,
},
}
impl ToolCallResult {
/// Check if this is a successful result.
pub fn is_success(&self) -> bool {
matches!(self, ToolCallResult::Success(_))
}
/// Check if this is an error result.
pub fn is_error(&self) -> bool {
matches!(self, ToolCallResult::Error { .. })
}
/// Get the success value if present.
pub fn get_success(&self) -> Option<&serde_json::Value> {
match self {
ToolCallResult::Success(v) => Some(v),
_ => None,
}
}
/// Get the error components if present.
pub fn get_error(&self) -> Option<(i64, &str, Option<&serde_json::Value>)> {
match self {
ToolCallResult::Error { code, message, data } => {
Some((*code, message.as_str(), data.as_ref()))
}
_ => None,
}
}
/// Check if this error has a specific error code in data.code field.
///
/// Used for checking specific diagnostic codes like "SSRF_BLOCKED".
pub fn has_error_code(&self, expected_code: &str) -> bool {
match self {
ToolCallResult::Error { data, .. } => {
if let Some(data_obj) = data {
if let Some(code) = data_obj.get("code").and_then(|c| c.as_str()) {
return code == expected_code;
}
}
false
}
_ => false,
}
}
}
/// Send an MCP tool call request and parse the response.
///
/// This helper function handles the complete request/response cycle for
/// MCP tool calls:
/// 1. Constructs a JSON-RPC tools/call request
/// 2. Writes it to the server's stdin with proper framing
/// 3. Reads the framed response from stdout
/// 4. Parses and returns a structured ToolCallResult
///
/// # Arguments
///
/// * `server` - Mutable reference to the spawned MCP server guard
/// * `tool_name` - Name of the tool to call (e.g., "extract", "get_metadata")
/// * `arguments` - Tool arguments as a JSON value (typically an object)
/// * `timeout_ms` - Maximum time to wait for response (default 5000ms recommended)
///
/// # Returns
///
/// * `Ok(ToolCallResult)` - Parsed response (success or error)
/// * `Err(String)` - I/O or parsing error with descriptive message
///
/// # Example
///
/// ```rust
/// use std::thread;
/// use std::time::Duration;
/// use serde_json::json;
///
/// let mut server = spawn_mcp_stdio();
/// thread::sleep(Duration::from_millis(50));
///
/// let result = send_tool_call(
/// &mut server,
/// "extract",
/// json!({"path": "https://example.com/doc.pdf"}),
/// 5000
/// ).expect("Tool call should succeed");
///
/// if result.is_error() && result.has_error_code("SSRF_BLOCKED") {
/// // URL was rejected as expected
/// }
/// ```
pub fn send_tool_call(
server: &mut std::process::Child,
tool_name: &str,
arguments: serde_json::Value,
timeout_ms: u64,
) -> Result<ToolCallResult, String> {
use std::io::{BufRead, BufReader, Write};
// Build the JSON-RPC request
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
});
let request_str = serde_json::to_string(&request)
.map_err(|e| format!("Failed to serialize request: {}", e))?;
// Write the framed request
{
let stdin = server.stdin.as_mut()
.ok_or_else(|| "Failed to get stdin handle".to_string())?;
let header = format!("Content-Length: {}\r\n\r\n", request_str.len());
stdin.write_all(header.as_bytes())
.map_err(|e| format!("Failed to write header: {}", e))?;
stdin.write_all(request_str.as_bytes())
.map_err(|e| format!("Failed to write request body: {}", e))?;
stdin.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
}
// Read the framed response with timeout
let start = std::time::Instant::now();
let response = loop {
let stdout = server.stdout.as_mut()
.ok_or_else(|| "Failed to get stdout handle".to_string())?;
let mut reader = BufReader::new(stdout);
match read_framed_response(&mut reader) {
Ok(Some(resp)) => break resp,
Ok(None) => return Err("Unexpected EOF while reading response".to_string()),
Err(e) if start.elapsed() >= std::time::Duration::from_millis(timeout_ms) => {
return Err(format!("Timeout waiting for response: {}", e));
}
Err(_) => {
std::thread::sleep(std::time::Duration::from_millis(10));
if start.elapsed() >= std::time::Duration::from_millis(timeout_ms) {
return Err("Timeout waiting for response".to_string());
}
continue;
}
}
};
// Parse the JSON-RPC response
let parsed: JsonRpcResponse<serde_json::Value> = parse_response(&response)?;
// Convert to ToolCallResult
if let Some(result_value) = parsed.result {
Ok(ToolCallResult::Success(result_value))
} else if let Some(error_obj) = parsed.error {
Ok(ToolCallResult::Error {
code: error_obj.code,
message: error_obj.message,
data: error_obj.data,
})
} else {
Err("Response has neither result nor error field".to_string())
}
}
/// Convenience function to send an extract tool call with a URL parameter.
///
/// This is the most common pattern for SSRF testing.
///
/// # Arguments
///
/// * `server` - Mutable reference to the spawned MCP server guard
/// * `url` - The URL to extract from (will be passed as the "path" argument)
/// * `timeout_ms` - Maximum time to wait for response (default 5000ms recommended)
///
/// # Returns
///
/// * `Ok(ToolCallResult)` - Parsed response (success or error)
/// * `Err(String)` - I/O or parsing error with descriptive message
pub fn send_extract_call(
server: &mut std::process::Child,
url: &str,
timeout_ms: u64,
) -> Result<ToolCallResult, String> {
let arguments = json!({
"path": url
});
send_tool_call(server, "extract", arguments, timeout_ms)
}
/// Convenience function to send a get_metadata tool call with a URL parameter.
///
/// # Arguments
///
/// * `server` - Mutable reference to the spawned MCP server guard
/// * `url` - The URL to fetch metadata from (will be passed as the "path" argument)
/// * `timeout_ms` - Maximum time to wait for response (default 5000ms recommended)
///
/// # Returns
///
/// * `Ok(ToolCallResult)` - Parsed response (success or error)
/// * `Err(String)` - I/O or parsing error with descriptive message
pub fn send_get_metadata_call(
server: &mut std::process::Child,
url: &str,
timeout_ms: u64,
) -> Result<ToolCallResult, String> {
let arguments = json!({
"path": url
});
send_tool_call(server, "get_metadata", arguments, timeout_ms)
}
mod mcp_helpers_tests {
use super::*;
#[test]
fn test_is_ssrf_blocked_error_with_code_in_data() {
let error_json = r#"{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "SSRF protection blocked this URL",
"data": {"code": "SSRF_BLOCKED"}
},
"id": 1
}"#;
let result = is_ssrf_blocked_error(error_json);
assert!(result.is_ok());
assert!(result.unwrap(), "Should detect SSRF_BLOCKED in data.code");
}
#[test]
fn test_is_ssrf_blocked_error_with_message() {
let error_json = r#"{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "SSRF_BLOCKED: URL targets private network"
},
"id": 1
}"#;
let result = is_ssrf_blocked_error(error_json);
assert!(result.is_ok());
assert!(result.unwrap(), "Should detect SSRF_BLOCKED in message");
}
#[test]
fn test_is_ssrf_blocked_error_not_blocked() {
let error_json = r#"{
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found"
},
"id": 1
}"#;
let result = is_ssrf_blocked_error(error_json);
assert!(result.is_ok());
assert!(!result.unwrap(), "Should not detect SSRF_BLOCKED");
}
#[test]
fn test_is_ssrf_blocked_error_success_response() {
let success_json = r#"{
"jsonrpc": "2.0",
"result": {"status": "ok"},
"id": 1
}"#;
let result = is_ssrf_blocked_error(success_json);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no error field"));
}
#[test]
fn test_extract_error_info_success() {
let error_json = r#"{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "SSRF_BLOCKED",
"data": {"url": "http://127.0.0.1/"}
},
"id": 1
}"#;
let (code, message, data) = extract_error_info(error_json).unwrap();
assert_eq!(code, -32001);
assert_eq!(message, "SSRF_BLOCKED");
assert!(data.is_some());
assert_eq!(data.unwrap()["url"], "http://127.0.0.1/");
}
#[test]
fn test_extract_error_info_not_an_error() {
let success_json = r#"{
"jsonrpc": "2.0",
"result": {"status": "ok"},
"id": 1
}"#;
let result = extract_error_info(success_json);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not an error"));
}
#[test]
fn test_parse_response_success() {
let success_json = r#"{
"jsonrpc": "2.0",
"result": {"tools": []},
"id": 1
}"#;
let response = parse_response(success_json).unwrap();
assert!(response.is_success());
assert!(!response.is_error());
}
#[test]
fn test_parse_response_error() {
let error_json = r#"{
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found"
},
"id": 1
}"#;
let response = parse_response(error_json).unwrap();
assert!(!response.is_success());
assert!(response.is_error());
assert_eq!(response.get_error().unwrap().code, -32601);
}
#[test]
fn test_parse_response_invalid_json() {
let invalid_json = r#"{not valid json}"#;
let result = parse_response(invalid_json);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to parse JSON-RPC response"));
}
#[test]
fn test_parse_response_missing_jsonrpc_field() {
let invalid_json = r#"{"result": {}, "id": 1}"#;
let result = parse_response(invalid_json);
assert!(result.is_err());
}
#[test]
fn test_read_framed_response_simple() {
let input = b"Content-Length: 25\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1}";
let mut reader = BufReader::new(&input[..]);
let result = read_framed_response(&mut reader);
assert!(result.is_ok());
let json_body = result.unwrap().unwrap();
assert_eq!(json_body, "{\"jsonrpc\":\"2.0\",\"id\":1}");
}
#[test]
fn test_read_framed_response_with_extra_whitespace() {
let input = b"Content-Length: 25\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1}";
let mut reader = BufReader::new(&input[..]);
let result = read_framed_response(&mut reader);
assert!(result.is_ok());
let json_body = result.unwrap().unwrap();
assert_eq!(json_body, "{\"jsonrpc\":\"2.0\",\"id\":1}");
}
#[test]
fn test_read_framed_response_eof() {
let input = b"";
let mut reader = BufReader::new(&input[..]);
let result = read_framed_response(&mut reader);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_read_framed_response_missing_content_length() {
let input = b"\r\n\r\n{\"jsonrpc\":\"2.0\"}";
let mut reader = BufReader::new(&input[..]);
let result = read_framed_response(&mut reader);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Missing Content-Length"));
}
#[test]
fn test_write_framed_message_simple() {
let mut buffer = Vec::new();
let json_body = r#"{"test":"data"}"#;
let result = write_framed_message(&mut buffer, json_body);
assert!(result.is_ok());
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("Content-Length: 15"));
assert!(output.contains("\r\n\r\n"));
assert!(output.contains(json_body));
}
#[test]
fn test_tool_call_result_success() {
let result_value = json!({"status": "ok", "data": {"items": []}});
let result = ToolCallResult::Success(result_value);
assert!(result.is_success());
assert!(!result.is_error());
assert!(result.get_success().is_some());
assert!(result.get_error().is_none());
}
#[test]
fn test_tool_call_result_error() {
let data = json!({"code": "SSRF_BLOCKED"});
let result = ToolCallResult::Error {
code: -32001,
message: "URL blocked".to_string(),
data: Some(data),
};
assert!(!result.is_success());
assert!(result.is_error());
assert!(result.get_success().is_none());
assert!(result.get_error().is_some());
let (code, message, data_opt) = result.get_error().unwrap();
assert_eq!(code, -32001);
assert_eq!(message, "URL blocked");
assert!(data_opt.is_some());
assert_eq!(data_opt.unwrap().get("code").unwrap().as_str().unwrap(), "SSRF_BLOCKED");
}
#[test]
fn test_tool_call_result_has_error_code() {
let data = json!({"code": "SSRF_BLOCKED"});
let result = ToolCallResult::Error {
code: -32001,
message: "URL blocked".to_string(),
data: Some(data),
};
assert!(result.has_error_code("SSRF_BLOCKED"));
assert!(!result.has_error_code("OTHER_ERROR"));
}
#[test]
fn test_tool_call_result_has_error_code_no_data() {
let result = ToolCallResult::Error {
code: -32001,
message: "URL blocked".to_string(),
data: None,
};
assert!(!result.has_error_code("SSRF_BLOCKED"));
}
#[test]
fn test_tool_call_result_has_error_code_malformed_data() {
let data = json!({"message": "something"});
let result = ToolCallResult::Error {
code: -32001,
message: "URL blocked".to_string(),
data: Some(data),
};
assert!(!result.has_error_code("SSRF_BLOCKED"));
}
// Tests for JsonRpcError::is_ssrf_blocked()
#[test]
fn test_json_rpc_error_is_ssrf_blocked_with_code_in_data() {
let error = JsonRpcError {
code: -32001,
message: "SSRF protection blocked this URL".to_string(),
data: Some(json!({"code": "SSRF_BLOCKED"})),
};
assert!(error.is_ssrf_blocked(), "Should detect SSRF_BLOCKED in data.code");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_with_message() {
let error = JsonRpcError {
code: -32001,
message: "SSRF_BLOCKED: URL targets private network".to_string(),
data: None,
};
assert!(error.is_ssrf_blocked(), "Should detect SSRF_BLOCKED in message");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_not_blocked() {
let error = JsonRpcError {
code: -32601,
message: "Method not found".to_string(),
data: None,
};
assert!(!error.is_ssrf_blocked(), "Should not detect SSRF_BLOCKED");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_empty_data() {
let error = JsonRpcError {
code: -32001,
message: "Some error".to_string(),
data: Some(json!({})),
};
assert!(!error.is_ssrf_blocked(), "Should not detect SSRF_BLOCKED with empty data");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_different_code_in_data() {
let error = JsonRpcError {
code: -32001,
message: "Some error".to_string(),
data: Some(json!({"code": "OTHER_ERROR"})),
};
assert!(!error.is_ssrf_blocked(), "Should not detect OTHER_ERROR as SSRF_BLOCKED");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_case_sensitive_in_message() {
let error = JsonRpcError {
code: -32001,
message: "ssrf_blocked: lowercase".to_string(),
data: None,
};
assert!(!error.is_ssrf_blocked(), "Should be case-sensitive in message");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_case_sensitive_in_data() {
let error = JsonRpcError {
code: -32001,
message: "Some error".to_string(),
data: Some(json!({"code": "ssrf_blocked"})),
};
assert!(!error.is_ssrf_blocked(), "Should be case-sensitive in data");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_partial_match_in_message() {
let error = JsonRpcError {
code: -32001,
message: "SSRF_BLOCKED detected in request".to_string(),
data: None,
};
assert!(error.is_ssrf_blocked(), "Should detect partial match in message");
}
#[test]
fn test_json_rpc_error_is_ssrf_blocked_both_data_and_message() {
let error = JsonRpcError {
code: -32001,
message: "SSRF_BLOCKED: URL rejected".to_string(),
data: Some(json!({"code": "SSRF_BLOCKED"})),
};
assert!(error.is_ssrf_blocked(), "Should detect SSRF_BLOCKED in both data and message");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_call_builder_extract_basic() {
let request = ToolCallBuilder::extract()
.with_url("https://example.com/")
.build();
assert_eq!(request.method, "tools/call");
assert!(request.params.is_some());
let params = request.params.unwrap();
assert_eq!(params["name"], "extract");
assert_eq!(params["arguments"]["path"], "https://example.com/");
}
#[test]
fn test_tool_call_builder_extract_with_id() {
let request = ToolCallBuilder::extract()
.with_url("https://example.com/")
.with_id(Id::String("test-123".to_string()))
.build();
assert_eq!(request.request_id(), Id::String("test-123".to_string()));
}
#[test]
fn test_tool_call_builder_with_custom_argument() {
let request = ToolCallBuilder::extract()
.with_url("https://example.com/")
.with_argument("ocr", serde_json::Value::Bool(true))
.build();
let params = request.params.unwrap();
assert_eq!(params["arguments"]["ocr"], true);
}
#[test]
fn test_extract_call_quick_helper() {
let request = extract_call("https://example.com/doc.pdf");
assert_eq!(request.method, "tools/call");
let params = request.params.unwrap();
assert_eq!(params["name"], "extract");
assert_eq!(params["arguments"]["path"], "https://example.com/doc.pdf");
}
#[test]
fn test_get_metadata_call_quick_helper() {
let request = get_metadata_call("https://example.com/doc.pdf");
assert_eq!(request.method, "tools/call");
let params = request.params.unwrap();
assert_eq!(params["name"], "get_metadata");
assert_eq!(
params["arguments"]["path"],
"https://example.com/doc.pdf"
);
}
#[test]
fn test_serialization_format() {
let request = ToolCallBuilder::extract()
.with_url("https://example.com/")
.build();
let json = serde_json::to_string(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["jsonrpc"], "2.0");
assert_eq!(parsed["method"], "tools/call");
assert_eq!(parsed["params"]["name"], "extract");
assert_eq!(parsed["params"]["arguments"]["path"], "https://example.com/");
assert_eq!(parsed["id"], 1);
}
#[test]
fn test_multiple_arguments() {
let request = ToolCallBuilder::extract()
.with_url("https://example.com/")
.with_argument("password", serde_json::Value::String("secret123".to_string()))
.with_argument("ocr", serde_json::Value::Bool(true))
.with_argument("pages", serde_json::Value::String("1-5".to_string()))
.build();
let params = request.params.unwrap();
let args = &params["arguments"];
assert_eq!(args["path"], "https://example.com/");
assert_eq!(args["password"], "secret123");
assert_eq!(args["ocr"], true);
assert_eq!(args["pages"], "1-5");
}
}
}
// ============================================================================
// MCP Server Integration Tests
// ============================================================================
#[cfg(feature = "remote")]
#[cfg(test)]
mod mcp_ssrf_tests {
//! MCP server integration tests for SSRF protection.
//!
//! These tests verify that when the MCP server receives URL parameters
//! through JSON-RPC tools/call, SSRF-prone URLs are properly rejected.
//!
//! Currently, the MCP server returns stub responses for all URLs
//! (remote extraction requires Phase 1.8). Once Phase 1.8 is implemented,
//! these tests should verify that:
//! 1. SSRF-prone URLs are rejected with SSRF_BLOCKED error
//! 2. No actual network connections are attempted
//! 3. Public URLs are accepted
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::Duration;
/// RAII guard for spawned child processes.
///
/// Ensures deterministic cleanup on Drop (even on panic) by:
/// 1. Killing the child process
/// 2. Waiting with a timeout to reap the zombie
///
/// Per CLAUDE.md test hygiene rules, this prevents orphaned processes
/// and hung tests.
struct ProcessGuard {
child: Option<Child>,
}
impl ProcessGuard {
/// Create a new guard from a spawned child process.
fn new(child: Child) -> Self {
Self { child: Some(child) }
}
/// Get a mutable reference to the child process.
fn child_mut(&mut self) -> &mut Child {
self.child.as_mut().expect("ProcessGuard: child taken without replacement")
}
/// Take ownership of the child, dropping the guard without cleanup.
///
/// Use this when you want manual control over cleanup.
fn take(mut self) -> Child {
self.child.take().expect("ProcessGuard: child already taken")
}
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
// Kill the process (signal-based, may fail if already dead)
let _ = child.kill();
// Wait with timeout to reap zombie, don't block forever
let _ = wait_with_timeout(&mut child, 1000);
}
}
}
/// SSRF test payloads for MCP server testing.
///
/// These are the critical payloads that MUST be rejected by the MCP server
/// when remote extraction is implemented (Phase 1.8).
const MCP_SSRF_PAYLOADS: &[&str] = &[
"http://127.0.0.1:9999/", // Loopback with non-standard port
"http://0.0.0.0/", // All interfaces
"http://169.254.169.254/latest/meta-data/", // AWS metadata endpoint
"http://10.0.0.1/internal", // RFC 1918 private network
"http://[::1]/", // IPv6 loopback
];
/// Helper to spawn the pdftract MCP server in stdio mode.
fn spawn_mcp_stdio() -> std::process::Child {
Command::new("pdftract")
.arg("mcp")
.arg("--stdio")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("Failed to spawn pdftract mcp --stdio")
}
/// Helper to write a framed JSON-RPC message to stdin.
fn write_framed_message(
stdin: &mut std::process::ChildStdin,
json_body: &str,
) -> std::io::Result<()> {
let header = format!("Content-Length: {}\r\n\r\n", json_body.len());
stdin.write_all(header.as_bytes())?;
stdin.write_all(json_body.as_bytes())?;
stdin.flush()
}
/// Helper to read a framed JSON-RPC response from stdout.
fn read_framed_response<R: std::io::Read>(
reader: &mut BufReader<R>,
) -> std::io::Result<Option<String>> {
let mut content_length: Option<usize> = None;
// Read headers until empty line
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
return Ok(None); // EOF
}
let line = line.trim_end_matches(|c| c == '\r' || c == '\n');
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = Some(
value
.trim()
.parse::<usize>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
);
}
}
let content_length = content_length.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Missing Content-Length header",
)
})?;
let mut buffer = vec![0u8; content_length];
reader.read_exact(&mut buffer)?;
Ok(Some(String::from_utf8(buffer).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?))
}
/// Wait for a process to complete with a timeout.
fn wait_with_timeout(
child: &mut std::process::Child,
timeout_ms: u64,
) -> std::io::Result<Option<i32>> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
loop {
if let Some(status) = child.try_wait()? {
return Ok(status.code());
}
if std::time::Instant::now() >= deadline {
// Timeout: kill the process
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Process timed out",
));
}
thread::sleep(Duration::from_millis(50));
}
}
/// Test that MCP server handles SSRF-prone URLs safely.
///
/// Current behavior: Returns stub response (remote extraction not implemented).
/// Expected behavior (Phase 1.8): Returns JSON-RPC error with SSRF_BLOCKED code.
#[test]
fn test_mcp_extract_tool_rejects_ssrf_urls() {
for url in MCP_SSRF_PAYLOADS {
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the helper to construct a proper JSON-RPC tools/call request
use crate::mcp_helpers::extract_call;
let request = extract_call(*url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str)
.expect("Failed to write request");
}
// Read the response
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
// Verify the response
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current behavior: stub response with _note
// Future behavior (Phase 1.8): should return error with SSRF_BLOCKED
if parsed.get("result").is_some() {
// Current implementation returns stub result
let result = &parsed["result"];
assert!(
result.get("_note").is_some(),
"SSRF URL '{}' should return stub response or error, got: {}",
url,
response
);
// Verify no actual network activity occurred
// (The stub response doesn't fetch the URL)
} else if parsed.get("error").is_some() {
// Future implementation: should return SSRF_BLOCKED error
let error = &parsed["error"];
let error_data = error.get("data");
// Once Phase 1.8 is implemented, this should check for SSRF_BLOCKED
if let Some(data) = error_data {
let _code = data.get("code").and_then(|c| c.as_str());
// Future: assert_eq!(_code, Some("SSRF_BLOCKED"));
}
} else {
panic!(
"SSRF URL '{}' should return stub response or error, got: {}",
url, response
);
}
// Clean shutdown
let _ = child.stdin.take();
let _ = wait_with_timeout(&mut child, 1000);
}
}
/// Test that MCP server doesn't attempt actual connections to SSRF URLs.
///
/// This verifies that even if a URL is passed, the server doesn't try to
/// fetch it (since remote extraction is not implemented yet).
#[test]
fn test_mcp_no_network_connections_to_ssrf_urls() {
// Use localhost:9999 which is unlikely to have a listener
let dangerous_url = "http://127.0.0.1:9999/test.pdf";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(dangerous_url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
// Read response within timeout
let start = std::time::Instant::now();
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let elapsed = start.elapsed();
// Should return quickly (no network timeout)
assert!(
elapsed < Duration::from_millis(500),
"Response should return quickly without network attempt, took {:?}",
elapsed
);
// Verify stub response (not an error from failed connection)
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
assert!(
parsed.get("result").is_some(),
"Should return stub result, not connection error"
);
// Clean shutdown
let _ = child.stdin.take();
let _ = wait_with_timeout(&mut child, 1000);
}
/// Test that IPv6 loopback is handled safely.
#[test]
fn test_mcp_ipv6_loopback_rejected() {
let ipv6_loopback_urls = &[
"http://[::1]/",
"http://[::1]:8080/test.pdf",
"http://[0:0:0:0:0:0:0:1]/", // Full form of ::1
];
for url in ipv6_loopback_urls {
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(*url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str)
.expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current: stub response
// Future: SSRF_BLOCKED error
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"IPv6 loopback URL '{}' should return valid response",
url
);
// Clean shutdown
let _ = child.stdin.take();
let _ = wait_with_timeout(&mut child, 1000);
}
}
/// Test that cloud metadata endpoints are blocked.
#[test]
fn test_mcp_cloud_metadata_endpoints_blocked() {
let metadata_urls = &[
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://168.63.129.16/latest/",
];
for url in metadata_urls {
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the helper to construct the request for get_metadata tool
use crate::mcp_helpers::get_metadata_call;
let request = get_metadata_call(*url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str)
.expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Should never succeed in accessing metadata endpoints
assert!(
parsed.get("error").is_some()
|| parsed
.get("result")
.and_then(|r| r.get("_note"))
.is_some(),
"Metadata endpoint '{}' should be blocked or return stub",
url
);
// Clean shutdown
let _ = child.stdin.take();
let _ = wait_with_timeout(&mut child, 1000);
}
}
/// Test cleanup: ensure no orphaned processes after test completes.
///
/// Per CLAUDE.md test hygiene rules, all spawned processes must be
/// cleaned up deterministically.
#[test]
fn test_mcp_process_cleanup_on_completion() {
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Send a simple request
let request = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, request).expect("Failed to write request");
}
// Read response
let _ = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
// Close stdin to trigger clean shutdown
drop(child.stdin.take());
// Wait for process to exit (should exit within 200ms)
let result = wait_with_timeout(&mut child, 200);
assert!(
result.is_ok(),
"Process should exit cleanly after stdin close"
);
// Verify exit code is 0 (success)
let exit_code = result.unwrap().unwrap();
assert_eq!(exit_code, 0, "Process should exit with code 0");
}
// ============================================================================
// Individual SSRF URL Pattern Tests
// ============================================================================
/// Test that IPv4 loopback (127.0.0.1:9999) is rejected.
///
/// Tests the extract tool with an IPv4 loopback address on a non-standard port.
/// This verifies that SSRF protection catches localhost variants even with
/// custom port numbers.
#[test]
fn test_mcp_ipv4_loopback_rejected() {
let url = "http://127.0.0.1:9999/";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the JSON-RPC helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current implementation returns stub response; future should return SSRF_BLOCKED
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"IPv4 loopback URL should return valid response"
);
// Clean shutdown
drop(child.stdin.take());
let _ = wait_with_timeout(&mut child, 1000);
}
/// Test that IPv4 all interfaces (0.0.0.0) is rejected.
///
/// Tests the extract tool with 0.0.0.0, which binds to all interfaces.
/// This is a dangerous SSRF target as it can access services listening
/// on any local interface.
#[test]
fn test_mcp_ipv4_all_interfaces_rejected() {
let url = "http://0.0.0.0/";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the JSON-RPC helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current implementation returns stub response; future should return SSRF_BLOCKED
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"IPv4 all-interfaces URL should return valid response"
);
// Clean shutdown
drop(child.stdin.take());
let _ = wait_with_timeout(&mut child, 1000);
}
/// Test that IPv6 loopback (::1) is rejected.
///
/// Tests the extract tool with the IPv6 loopback address. This verifies
/// that SSRF protection handles IPv6 notation correctly, including the
/// bracket format for URLs.
#[test]
fn test_mcp_ipv6_loopback_rejected() {
let url = "http://[::1]/";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the JSON-RPC helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current implementation returns stub response; future should return SSRF_BLOCKED
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"IPv6 loopback URL should return valid response"
);
// Clean shutdown
drop(child.stdin.take());
let _ = wait_with_timeout(&mut child, 1000);
}
/// Test that AWS metadata endpoint (169.254.169.254) is rejected.
///
/// Tests the extract tool with the AWS IMDSv2 endpoint path. This is a
/// critical SSRF payload as cloud metadata endpoints can expose instance
/// credentials and sensitive metadata.
#[test]
fn test_mcp_aws_metadata_endpoint_rejected() {
let url = "http://169.254.169.254/latest/meta-data/";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the JSON-RPC helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current implementation returns stub response; future should return SSRF_BLOCKED
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"AWS metadata endpoint URL should return valid response"
);
// Clean shutdown
drop(child.stdin.take());
let _ = wait_with_timeout(&mut child, 1000);
}
/// Test that RFC 1918 private network (10.0.0.1) is rejected.
///
/// Tests the extract tool with a private network IP from the 10.0.0.0/8 range.
/// This verifies that SSRF protection blocks access to internal network
/// services that are not publicly accessible.
#[test]
fn test_mcp_private_network_rejected() {
let url = "http://10.0.0.1/internal";
let mut child = spawn_mcp_stdio();
thread::sleep(Duration::from_millis(50));
// Use the JSON-RPC helper to construct the request
use crate::mcp_helpers::extract_call;
let request = extract_call(url);
let request_str = serde_json::to_string(&request).unwrap();
{
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
write_framed_message(stdin, &request_str).expect("Failed to write request");
}
let response = {
let stdout = child.stdout.as_mut().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
read_framed_response(&mut reader)
.expect("Failed to read response")
.expect("No response received")
};
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Response is not valid JSON");
// Current implementation returns stub response; future should return SSRF_BLOCKED
assert!(
parsed.get("result").is_some() || parsed.get("error").is_some(),
"Private network URL should return valid response"
);
// Clean shutdown
drop(child.stdin.take());
let _ = wait_with_timeout(&mut child, 1000);
}
}