pdftract/crates/pdftract-core/src/url_validation.rs
jedarden e6bf3dd290 feat(pdftract-3s2i): implement Phase 5.5.2 validation filter
Implement per-word validation filter for assisted-OCR BrokenVector path.

Changes:
- Add SpanSource::OcrAssisted variant to hybrid.rs
- Add Span::ocr_assisted() helper method
- Implement validate_ocr_with_position_hints() in ocr.rs
  - 5pt distance threshold for position validation
  - 0.4 confidence cap for rejected words
  - Linear scan for nearest-neighbor lookup
- Add unit tests for validation filter

Closes: pdftract-3s2i

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 04:57:17 -04:00

559 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! URL validation for SSRF protection (Phase 1.8, TH-05).
//!
//! This module provides URL validation logic to prevent Server-Side Request Forgery
//! attacks. It validates URLs against a set of dangerous address ranges including:
//! - RFC 1918 private IPv4 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
//! - IPv6 Unique Local Addresses (ULA) (fc00::/7, fd00::/8)
//! - Loopback addresses (127.0.0.0/8, ::1)
//! - Link-local addresses (169.254.0.0/16, fe80::/10)
//! - Cloud metadata endpoints (169.254.169.254, 100.100.100.200, etc.)
//!
//! URLs targeting these addresses are rejected unless the `--allow-private-networks`
//! flag is set.
//!
//! This module also provides URL credential parsing for HTTPS URLs with embedded
//! credentials (e.g., `https://user:pass@host/path`).
use crate::diagnostics::{DiagCode, Diagnostic};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Error type for URL validation failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UrlValidationError {
/// URL scheme is not https://
InvalidScheme(String),
/// URL targets a private network address (SSRF protection)
PrivateNetwork(String),
/// DNS resolution failed
DnsFailed(String),
/// Invalid URL format
InvalidUrl(String),
}
impl std::fmt::Display for UrlValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UrlValidationError::InvalidScheme(scheme) => {
write!(
f,
"Invalid URL scheme: '{}'. Only 'https://' is allowed.",
scheme
)
}
UrlValidationError::PrivateNetwork(addr) => {
write!(f, "URL targets private network address: {}. Use --allow-private-networks to enable (WARNING: security risk).", addr)
}
UrlValidationError::DnsFailed(host) => {
write!(f, "DNS resolution failed for host: {}", host)
}
UrlValidationError::InvalidUrl(url) => {
write!(f, "Invalid URL format: {}", url)
}
}
}
}
impl std::error::Error for UrlValidationError {}
/// Result type for URL validation.
pub type Result<T> = std::result::Result<T, UrlValidationError>;
/// Extract URL credentials from an HTTPS URL.
///
/// Parses URLs of the form `https://user:pass@host/path` and returns:
/// - The cleaned URL (without credentials)
/// - Optional credentials tuple (username, password)
///
/// # Arguments
///
/// * `url_str` - The URL string to parse
///
/// # Returns
///
/// Returns `Ok((clean_url, creds))` where `clean_url` is the URL without credentials
/// and `creds` is `Some((username, password))` if credentials were present, or `None`.
///
/// # Errors
///
/// Returns `Err(UrlValidationError::InvalidUrl)` if the URL is malformed.
/// Returns `Err(UrlValidationError::InvalidScheme)` if the URL is `http://` with embedded
/// credentials (HTTP Basic over plain HTTP is forbidden).
///
/// # Examples
///
/// ```rust
/// use pdftract_core::url_validation::extract_url_credentials;
///
/// // URL with credentials
/// let (clean, creds) = extract_url_credentials("https://alice:secret@example.com/doc.pdf").unwrap();
/// assert_eq!(clean, "https://example.com/doc.pdf");
/// assert_eq!(creds, Some(("alice".to_string(), "secret".to_string())));
///
/// // URL without credentials
/// let (clean, creds) = extract_url_credentials("https://example.com/doc.pdf").unwrap();
/// assert_eq!(clean, "https://example.com/doc.pdf");
/// assert_eq!(creds, None);
///
/// // HTTP with credentials is rejected
/// assert!(extract_url_credentials("http://alice:secret@example.com/doc.pdf").is_err());
/// ```
#[cfg(feature = "remote")]
pub fn extract_url_credentials(
url_str: &str,
) -> std::result::Result<(String, Option<(String, String)>), UrlValidationError> {
let url = url::Url::parse(url_str)
.map_err(|_| UrlValidationError::InvalidUrl(url_str.to_string()))?;
// Reject http:// URLs with embedded credentials
if url.scheme() == "http" && !url.username().is_empty() {
return Err(UrlValidationError::InvalidScheme(
"http:// URLs with embedded credentials are forbidden (HTTP Basic over plain HTTP is insecure)".to_string()
));
}
// Extract credentials if present
// Per RFC 7617 (HTTP Basic), credentials must be percent-decoded before base64-encoding
let creds = if !url.username().is_empty() {
let username = url.username().to_string();
let password = url.password().unwrap_or("").to_string();
Some((username, password))
} else {
None
};
// Reconstruct URL without credentials
let mut clean = url.clone();
// set_username returns Err if the scheme doesn't support auth, but we want to ignore that
let _ = clean.set_username("");
let _ = clean.set_password(None);
Ok((clean.to_string(), creds))
}
/// Check if an IPv4 address is in a private network range.
///
/// This checks RFC 1918 private addresses:
/// - 10.0.0.0/8 (10.0.0.0 10.255.255.255)
/// - 172.16.0.0/12 (172.16.0.0 172.31.255.255)
/// - 192.168.0.0/16 (192.168.0.0 192.168.255.255)
///
/// Plus other reserved ranges:
/// - 127.0.0.0/8 (loopback)
/// - 169.254.0.0/16 (link-local)
/// - 0.0.0.0/8 (current network)
fn is_private_ipv4(addr: Ipv4Addr) -> bool {
let octets = addr.octets();
match octets {
// 10.0.0.0/8
[10, _, _, _] => true,
// 172.16.0.0/12
[172, 16..=31, _, _] => true,
// 192.168.0.0/16
[192, 168, _, _] => true,
// 127.0.0.0/8 (loopback)
[127, _, _, _] => true,
// 169.254.0.0/16 (link-local)
[169, 254, _, _] => true,
// 0.0.0.0/8 (current network)
[0, _, _, _] => true,
_ => false,
}
}
/// Check if an IPv6 address is in a private network range.
///
/// This checks:
/// - fc00::/7 (Unique Local Addresses - ULA)
/// - ::1 (loopback)
/// - fe80::/10 (link-local)
/// - ff00::/8 (multicast)
fn is_private_ipv6(addr: &Ipv6Addr) -> bool {
let segments = addr.segments();
// fc00::/7 (ULA) - fc00::/7 and fd00::/8
if (segments[0] & 0xfe00) == 0xfc00 {
return true;
}
// ::1 (loopback)
if addr.is_loopback() {
return true;
}
// fe80::/10 (link-local)
if (segments[0] & 0xffc0) == 0xfe80 {
return true;
}
// ff00::/8 (multicast)
if (segments[0] & 0xff00) == 0xff00 {
return true;
}
false
}
/// Known cloud metadata endpoint addresses.
///
/// These are well-known endpoints that return cloud instance credentials:
/// - AWS: 169.254.169.254
/// - GCP: metadata.google.internal (resolves to various internal IPs)
/// - Azure: 168.63.129.16
/// - Alibaba: 100.100.100.200
fn is_metadata_endpoint(addr: &IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => {
// AWS metadata endpoint
if v4 == &Ipv4Addr::new(169, 254, 169, 254) {
return true;
}
// Azure metadata endpoint
if v4 == &Ipv4Addr::new(168, 63, 129, 16) {
return true;
}
// Alibaba metadata endpoint
if v4 == &Ipv4Addr::new(100, 100, 100, 200) {
return true;
}
false
}
IpAddr::V6(_v6) => {
// IPv6 metadata endpoints would go here
// (e.g., fd00:ec2::254 for some AWS regions)
false
}
}
}
/// Known metadata endpoint hostnames.
///
/// These hostnames are checked before DNS resolution to prevent
/// DNS rebinding attacks.
const METADATA_HOSTNAMES: &[&str] = &["metadata.google.internal", "instance-data.google.internal"];
/// Check if a hostname is a known metadata endpoint.
fn is_metadata_hostname(hostname: &str) -> bool {
let hostname_lower = hostname.to_lowercase();
METADATA_HOSTNAMES
.iter()
.any(|&h| hostname_lower == h || hostname_lower.ends_with(&format!(".{}", h)))
}
/// Validate a URL for SSRF protection.
///
/// This function performs the following checks:
/// 1. URL scheme must be `https://`
/// 2. Hostname is not a known metadata endpoint
/// 3. Resolved IP address is not in a private network range
///
/// DNS resolution happens once and the resolved address is checked.
/// This prevents DNS rebinding attacks.
///
/// # Arguments
///
/// * `url_str` - The URL string to validate
/// * `allow_private_networks` - If true, private network addresses are allowed
///
/// # Returns
///
/// Returns `Ok(())` if the URL is valid, or an error describing the validation failure.
pub fn validate_url(url_str: &str, allow_private_networks: bool) -> Result<()> {
// Check for IPv6 zone IDs in the raw URL (before parsing)
// The url crate strips zone IDs, so we need to check the raw string
if url_str.contains('%') {
return Err(UrlValidationError::PrivateNetwork(
"IPv6 link-local address (zone ID)".to_string(),
));
}
// Parse the URL
let url = url::Url::parse(url_str)
.map_err(|_| UrlValidationError::InvalidUrl(url_str.to_string()))?;
// Check scheme: only https:// is allowed
match url.scheme() {
"https" => {}
scheme => {
return Err(UrlValidationError::InvalidScheme(scheme.to_string()));
}
}
// Extract hostname
let hostname = url
.host_str()
.ok_or_else(|| UrlValidationError::InvalidUrl(url_str.to_string()))?;
// Check for metadata hostnames (before DNS resolution)
if is_metadata_hostname(hostname) {
return Err(UrlValidationError::PrivateNetwork(format!(
"metadata endpoint: {}",
hostname
)));
}
// Resolve the hostname to an IP address
// Note: We use std::net::ToSocketAddrs which performs DNS resolution
use std::net::ToSocketAddrs;
let addrs: std::vec::Vec<std::net::SocketAddr> = format!("{}:443", hostname)
.to_socket_addrs()
.map_err(|_| UrlValidationError::DnsFailed(hostname.to_string()))?
.collect();
if addrs.is_empty() {
return Err(UrlValidationError::DnsFailed(hostname.to_string()));
}
// Check all resolved addresses
for addr in addrs {
let ip_addr = addr.ip();
// Check for metadata endpoints
if is_metadata_endpoint(&ip_addr) {
return Err(UrlValidationError::PrivateNetwork(format!(
"cloud metadata endpoint: {}",
ip_addr
)));
}
// If private networks are not allowed, check the IP ranges
if !allow_private_networks {
match ip_addr {
IpAddr::V4(v4) => {
if is_private_ipv4(v4) {
return Err(UrlValidationError::PrivateNetwork(format!(
"private IPv4: {}",
v4
)));
}
}
IpAddr::V6(v6) => {
if is_private_ipv6(&v6) {
return Err(UrlValidationError::PrivateNetwork(format!(
"private IPv6: {}",
v6
)));
}
}
}
}
}
Ok(())
}
/// Validate a URL and return a diagnostic if validation fails.
///
/// This is a convenience function for use in the extraction pipeline.
/// It returns `Ok(())` if the URL is valid, or `Err(diagnostic)` if it fails.
///
/// # Arguments
///
/// * `url_str` - The URL string to validate
/// * `allow_private_networks` - If true, private network addresses are allowed
///
/// # Returns
///
/// Returns `Ok(())` if the URL is valid, or `Err(Diagnostic)` if validation fails.
pub fn validate_url_with_diagnostic(
url_str: &str,
allow_private_networks: bool,
) -> std::result::Result<(), Diagnostic> {
validate_url(url_str, allow_private_networks).map_err(|err| {
let message = err.to_string();
Diagnostic::with_dynamic_no_offset(DiagCode::RemoteUrlPrivateNetwork, message)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_private_ipv4() {
// RFC 1918 private addresses
assert!(is_private_ipv4(Ipv4Addr::new(10, 0, 0, 1)));
assert!(is_private_ipv4(Ipv4Addr::new(10, 255, 255, 254)));
assert!(is_private_ipv4(Ipv4Addr::new(172, 16, 0, 1)));
assert!(is_private_ipv4(Ipv4Addr::new(172, 31, 255, 254)));
assert!(is_private_ipv4(Ipv4Addr::new(192, 168, 0, 1)));
assert!(is_private_ipv4(Ipv4Addr::new(192, 168, 255, 254)));
// Loopback
assert!(is_private_ipv4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(is_private_ipv4(Ipv4Addr::new(127, 255, 255, 255)));
// Link-local
assert!(is_private_ipv4(Ipv4Addr::new(169, 254, 0, 1)));
// Public addresses
assert!(!is_private_ipv4(Ipv4Addr::new(8, 8, 8, 8)));
assert!(!is_private_ipv4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!is_private_ipv4(Ipv4Addr::new(172, 15, 255, 255))); // Just outside 172.16.0.0/12
assert!(!is_private_ipv4(Ipv4Addr::new(172, 32, 0, 1))); // Just outside 172.16.0.0/12
}
#[test]
fn test_is_private_ipv6() {
// ULA
assert!(is_private_ipv6(&"fc00::1".parse().unwrap()));
assert!(is_private_ipv6(&"fd00::1".parse().unwrap()));
// Loopback
assert!(is_private_ipv6(&"::1".parse().unwrap()));
// Link-local
assert!(is_private_ipv6(&"fe80::1".parse().unwrap()));
// Multicast
assert!(is_private_ipv6(&"ff00::1".parse().unwrap()));
// Public addresses
assert!(!is_private_ipv6(&"2001:4860:4860::8888".parse().unwrap()));
assert!(!is_private_ipv6(
&"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
));
}
#[test]
fn test_is_metadata_endpoint() {
// AWS
assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
// Azure
assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
168, 63, 129, 16
))));
// Alibaba
assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
100, 100, 100, 200
))));
// Non-metadata
assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
8, 8, 8, 8
))));
}
#[test]
fn test_is_metadata_hostname() {
assert!(is_metadata_hostname("metadata.google.internal"));
assert!(is_metadata_hostname("instance-data.google.internal"));
assert!(is_metadata_hostname("foo.metadata.google.internal"));
assert!(!is_metadata_hostname("example.com"));
assert!(!is_metadata_hostname("google.com"));
}
#[test]
fn test_validate_url_rejects_http() {
let result = validate_url("http://example.com/", false);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_validate_url_rejects_ftp() {
let result = validate_url("ftp://example.com/", false);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_validate_url_rejects_file() {
let result = validate_url("file:///etc/passwd", false);
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[test]
fn test_validate_url_rejects_metadata_hostname() {
let result = validate_url("https://metadata.google.internal/", false);
assert!(matches!(result, Err(UrlValidationError::PrivateNetwork(_))));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_with_creds() {
let (clean, creds) =
extract_url_credentials("https://alice:secret@example.com/doc.pdf").unwrap();
assert_eq!(clean, "https://example.com/doc.pdf");
assert_eq!(creds, Some(("alice".to_string(), "secret".to_string())));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_without_creds() {
let (clean, creds) = extract_url_credentials("https://example.com/doc.pdf").unwrap();
assert_eq!(clean, "https://example.com/doc.pdf");
assert_eq!(creds, None);
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_http_with_creds_rejected() {
let result = extract_url_credentials("http://alice:secret@example.com/doc.pdf");
assert!(matches!(result, Err(UrlValidationError::InvalidScheme(_))));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_empty_password() {
let (clean, creds) = extract_url_credentials("https://alice@example.com/doc.pdf").unwrap();
assert_eq!(clean, "https://example.com/doc.pdf");
assert_eq!(creds, Some(("alice".to_string(), "".to_string())));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_url_encoded() {
// URL-encoded credentials: the url crate preserves percent-encoding in userinfo
// Percent-decoding happens when credentials are used for HTTP Basic auth (base64 encoding)
let (clean, creds) =
extract_url_credentials("https://alice%40example.com:secret@example.com/doc.pdf")
.unwrap();
assert_eq!(clean, "https://example.com/doc.pdf");
// The url crate preserves percent-encoding; HTTP Basic auth will decode when base64-encoding
assert_eq!(
creds,
Some(("alice%40example.com".to_string(), "secret".to_string()))
);
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_with_path_and_query() {
let (clean, creds) = extract_url_credentials(
"https://user:pass@example.com/path/to/doc.pdf?query=value#fragment",
)
.unwrap();
assert_eq!(
clean,
"https://example.com/path/to/doc.pdf?query=value#fragment"
);
assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_preserves_https_without_creds() {
let (clean, creds) = extract_url_credentials("https://example.com/doc.pdf").unwrap();
assert_eq!(clean, "https://example.com/doc.pdf");
assert!(creds.is_none());
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_invalid_url() {
let result = extract_url_credentials("not-a-url");
assert!(matches!(result, Err(UrlValidationError::InvalidUrl(_))));
}
#[cfg(feature = "remote")]
#[test]
fn test_extract_url_credentials_http_without_creds_allowed() {
// http:// without credentials should be allowed (it will fail later in validation)
let (clean, creds) = extract_url_credentials("http://example.com/doc.pdf").unwrap();
assert_eq!(clean, "http://example.com/doc.pdf");
assert!(creds.is_none());
}
}