forked from mirrors/kingfisher
Merge pull request #300 from mongodb/development
This commit is contained in:
commit
3d9ffd936d
21 changed files with 656 additions and 57 deletions
|
|
@ -3,6 +3,9 @@
|
|||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.91.0]
|
||||
- Added SSRF protection for credential validation: outbound HTTP requests now block connections to loopback, private, link-local, and other non-public IP addresses. HTTP redirect targets are DNS-resolved and validated against the same SSRF rules. Use `--allow-internal-ips` to opt out when scanning internal infrastructure.
|
||||
- Consolidated JWT SSRF checks to use the shared `is_ssrf_safe_ip` function, covering additional reserved ranges (CGNAT, documentation, benchmarking, IPv6 unique-local).
|
||||
- Removed `ipnet` dependency from `kingfisher-scanner` (no longer needed).
|
||||
- Remediated current RustSec vulnerability findings by upgrading core dependencies including `gix`, `mysql_async`, `axum`, `indicatif`, `quick-xml`, and `console`.
|
||||
- Added `make audit-deps` to run `cargo audit` locally and report vulnerable dependencies.
|
||||
- Refreshed pinned GitHub Actions for `swatinem/rust-cache`, `msys2/setup-msys2`, and `ncipollo/release-action`, and configured Dependabot to ignore selected GitHub Action major-version bumps.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ validation-gcp = [
|
|||
validation-jwt = [
|
||||
"validation-http",
|
||||
"dep:chrono",
|
||||
"dep:ipnet",
|
||||
"dep:jsonwebtoken",
|
||||
"dep:tokio",
|
||||
]
|
||||
|
|
@ -166,7 +165,7 @@ sha2 = { workspace = true, optional = true }
|
|||
pem = { version = "3.0.6", optional = true }
|
||||
percent-encoding = { workspace = true, optional = true }
|
||||
ring = { version = "0.17", optional = true }
|
||||
ipnet = { version = "2.11", optional = true }
|
||||
|
||||
jsonwebtoken = { version = "10.2.0", features = ["aws-lc-rs"], optional = true }
|
||||
p256 = { version = "0.13.2", optional = true }
|
||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"], optional = true }
|
||||
|
|
@ -196,3 +195,4 @@ rand = { version = "0.10", optional = true }
|
|||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3.23"
|
||||
tokio = { version = "1.48", features = ["macros", "rt"] }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::{collections::BTreeMap, future::Future, str::FromStr, time::Duration};
|
||||
use std::{collections::BTreeMap, future::Future, net::IpAddr, str::FromStr, time::Duration};
|
||||
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use http::StatusCode;
|
||||
|
|
@ -14,6 +14,20 @@ use sha1::{Digest, Sha1};
|
|||
use tokio::{net::lookup_host, time::sleep};
|
||||
use tracing::debug;
|
||||
|
||||
/// Error returned by [`check_url_resolvable`] when an IP address fails the
|
||||
/// SSRF safety check. Callers can downcast `Box<dyn Error>` to distinguish
|
||||
/// SSRF blocks from other resolution failures.
|
||||
#[derive(Debug)]
|
||||
pub struct SsrfBlockedError(pub String);
|
||||
|
||||
impl std::fmt::Display for SsrfBlockedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfBlockedError {}
|
||||
|
||||
use super::GLOBAL_USER_AGENT;
|
||||
use kingfisher_rules::ResponseMatcher;
|
||||
|
||||
|
|
@ -392,10 +406,373 @@ pub fn validate_response(
|
|||
word_ok && status_ok && header_ok && json_ok && xml_ok && html_ok
|
||||
}
|
||||
|
||||
/// Check if a URL can be resolved via DNS.
|
||||
pub async fn check_url_resolvable(url: &Url) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// Returns `true` if the IP address is safe for outbound validation requests
|
||||
/// (i.e., it is a publicly routable address, not internal/reserved).
|
||||
///
|
||||
/// Blocks common IANA special-purpose ranges from RFC 6890 and RFC 8190.
|
||||
pub fn is_ssrf_safe_ip(ip: &IpAddr) -> bool {
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// 0.0.0.0/8 — "This host on this network" (RFC 1122); not routable
|
||||
if octets[0] == 0 {
|
||||
return false;
|
||||
}
|
||||
// Private ranges (RFC 1918)
|
||||
if octets[0] == 10 {
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return false;
|
||||
}
|
||||
// Link-local (169.254.0.0/16) — includes AWS metadata 169.254.169.254
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return false;
|
||||
}
|
||||
// CGNAT / Shared Address Space (100.64.0.0/10)
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
return false;
|
||||
}
|
||||
// IANA Special Purpose (192.0.0.0/24, RFC 6890)
|
||||
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
|
||||
return false;
|
||||
}
|
||||
// Documentation ranges (RFC 5737)
|
||||
if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 {
|
||||
return false;
|
||||
}
|
||||
// 6to4 relay anycast (192.88.99.0/24, RFC 7526 — deprecated)
|
||||
if octets[0] == 192 && octets[1] == 88 && octets[2] == 99 {
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
|
||||
return false;
|
||||
}
|
||||
// Benchmarking (198.18.0.0/15)
|
||||
if octets[0] == 198 && (18..=19).contains(&octets[1]) {
|
||||
return false;
|
||||
}
|
||||
// Reserved for future use (240.0.0.0/4) — not routable
|
||||
if octets[0] >= 240 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
// IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) — apply IPv4 checks
|
||||
// to prevent bypassing via e.g. ::ffff:127.0.0.1 or ::ffff:10.0.0.1
|
||||
if let Some(mapped) = v6.to_ipv4_mapped() {
|
||||
return is_ssrf_safe_ip(&IpAddr::V4(mapped));
|
||||
}
|
||||
let segments = v6.segments();
|
||||
// IPv4-compatible IPv6 addresses (::/96, e.g., ::127.0.0.1) are
|
||||
// deprecated (RFC 4291 §2.5.5.1) and can bypass IPv4-only checks.
|
||||
// Reject the entire ::/96 range.
|
||||
if segments[..6].iter().all(|&s| s == 0) {
|
||||
return false;
|
||||
}
|
||||
// Unique local (fc00::/7)
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
return false;
|
||||
}
|
||||
// Link-local (fe80::/10)
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
// Site-local (fec0::/10) — deprecated (RFC 3879) but still non-routable
|
||||
if segments[0] & 0xffc0 == 0xfec0 {
|
||||
return false;
|
||||
}
|
||||
// Benchmarking (2001:2::/48, RFC 5180)
|
||||
if segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0 {
|
||||
return false;
|
||||
}
|
||||
// Documentation (2001:db8::/32)
|
||||
if segments[0] == 0x2001 && segments[1] == 0x0db8 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a URL can be resolved via DNS, with SSRF protection against
|
||||
/// internal/private IP addresses.
|
||||
///
|
||||
/// **Note:** This is a preflight check — the HTTP client will perform its own
|
||||
/// DNS resolution when connecting. A DNS-rebinding attack could theoretically
|
||||
/// return a public IP for this check and a private IP for the actual connection.
|
||||
/// Fully eliminating this TOCTOU gap would require a custom resolver/connector
|
||||
/// that pins resolved IPs. In practice, callers should also disable automatic
|
||||
/// redirects or use a redirect-blocking policy on the HTTP client to mitigate
|
||||
/// the most practical exploitation paths.
|
||||
pub async fn check_url_resolvable(
|
||||
url: &Url,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let host = url.host_str().ok_or("No host in URL")?;
|
||||
|
||||
// If the host is already an IP literal, check it directly without DNS.
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
if !allow_internal_ips && !is_ssrf_safe_ip(&ip) {
|
||||
return Err(SsrfBlockedError(format!(
|
||||
"SSRF protection: resolved IP {} for host '{}' is not a public address. \
|
||||
Use --allow-internal-ips to permit internal addresses.",
|
||||
ip, host
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Hostname — resolve via DNS and check each resolved address.
|
||||
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
let addr = format!("{}:{}", host, port);
|
||||
lookup_host(addr).await?.next().ok_or_else(|| "Failed to resolve URL".into()).map(|_| ())
|
||||
let mut resolved_any = false;
|
||||
for socket_addr in lookup_host(&addr).await? {
|
||||
resolved_any = true;
|
||||
if !allow_internal_ips && !is_ssrf_safe_ip(&socket_addr.ip()) {
|
||||
return Err(SsrfBlockedError(format!(
|
||||
"SSRF protection: resolved IP {} for host '{}' is not a public address. \
|
||||
Use --allow-internal-ips to permit internal addresses.",
|
||||
socket_addr.ip(),
|
||||
host
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if !resolved_any {
|
||||
return Err("Failed to resolve URL".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backwards-compatible wrapper: checks URL resolvability with SSRF protection
|
||||
/// enabled (i.e., `allow_internal_ips = false`).
|
||||
#[deprecated(since = "0.1.0", note = "use check_url_resolvable(url, allow_internal_ips) instead")]
|
||||
pub async fn check_url_resolvable_safe(url: &Url) -> Result<(), Box<dyn std::error::Error>> {
|
||||
check_url_resolvable(url, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_loopback() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_unspecified() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_this_network() {
|
||||
// 0.0.0.0/8 — "This host on this network" (RFC 1122)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(0, 255, 255, 255))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_private_rfc1918() {
|
||||
// 10.0.0.0/8
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255))));
|
||||
// 172.16.0.0/12
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
|
||||
// 192.168.0.0/16
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 255, 255))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_link_local_and_metadata() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
|
||||
// AWS/GCP/Azure metadata endpoint
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cgnat() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(100, 127, 255, 255))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_documentation_ranges() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_benchmarking() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_and_broadcast() {
|
||||
// 240.0.0.0/4 — reserved for future use (includes broadcast)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(250, 1, 2, 3))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::BROADCAST)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multicast() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_loopback() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_unspecified() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_unique_local() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_link_local() {
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_site_local() {
|
||||
// fec0::/10 — deprecated site-local (RFC 3879)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0xfec0, 0, 0, 0, 0, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0xfeff, 0, 0, 0, 0, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_documentation() {
|
||||
// 2001:db8::/32 — documentation range (RFC 3849)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1))));
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2001, 0x0db8, 0xffff, 0, 0, 0, 0, 1
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_mapped_ipv6() {
|
||||
// ::ffff:127.0.0.1 — IPv4-mapped loopback
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001
|
||||
))));
|
||||
// ::ffff:10.0.0.1 — IPv4-mapped private
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001
|
||||
))));
|
||||
// ::ffff:169.254.169.254 — IPv4-mapped metadata endpoint
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe
|
||||
))));
|
||||
// ::ffff:192.168.1.1 — IPv4-mapped private
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0101
|
||||
))));
|
||||
// ::ffff:8.8.8.8 — IPv4-mapped public (should be allowed)
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv4_compatible_ipv6() {
|
||||
// ::127.0.0.1 — deprecated IPv4-compatible IPv6 (loopback)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7f00, 0x0001))));
|
||||
// ::10.0.0.1 — deprecated IPv4-compatible IPv6 (private)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x0a00, 0x0001))));
|
||||
// ::8.8.8.8 — even public IPv4 in ::/96 is rejected (deprecated range)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x0808, 0x0808))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_iana_special_purpose() {
|
||||
// 192.0.0.0/24 — IANA special-purpose (RFC 6890)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_6to4_relay_anycast() {
|
||||
// 192.88.99.0/24 — 6to4 relay anycast (RFC 7526, deprecated)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(192, 88, 99, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_benchmarking() {
|
||||
// 2001:2::/48 — benchmarking (RFC 5180)
|
||||
assert!(!is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0x2001, 0x0002, 0, 0, 0, 0, 0, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_public_ipv4() {
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_public_ipv6() {
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 0x1111))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_edge_cases_outside_private_ranges() {
|
||||
// 172.15.x.x is NOT private (private is 172.16-31)
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(172, 15, 255, 255))));
|
||||
// 172.32.x.x is NOT private
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1))));
|
||||
// 100.63.x.x is NOT CGNAT (CGNAT is 100.64-127)
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255))));
|
||||
// 100.128.x.x is NOT CGNAT
|
||||
assert!(is_ssrf_safe_ip(&IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_url_resolvable_rejects_localhost() {
|
||||
let url = Url::parse("https://localhost/test").unwrap();
|
||||
let result = check_url_resolvable(&url, false).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("SSRF protection"), "expected SSRF error, got: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_url_resolvable_allows_localhost_when_permitted() {
|
||||
let url = Url::parse("https://localhost/test").unwrap();
|
||||
// With allow_internal_ips=true, localhost should resolve successfully
|
||||
let result = check_url_resolvable(&url, true).await;
|
||||
assert!(result.is_ok(), "expected Ok with allow_internal_ips=true, got: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_url_resolvable_rejects_ipv6_loopback_literal() {
|
||||
// IPv6 literal URL — brackets are handled by reqwest::Url, host_str() returns "::1"
|
||||
let url = Url::parse("https://[::1]/test").unwrap();
|
||||
let result = check_url_resolvable(&url, false).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("SSRF protection"), "expected SSRF error, got: {}", err);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
use super::http_validation::{check_url_resolvable, SsrfBlockedError};
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use jsonwebtoken::{
|
||||
decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation as JwtValidation,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{redirect::Policy, Client, Url};
|
||||
use serde::Deserialize;
|
||||
use tokio::net::lookup_host;
|
||||
|
||||
use super::http_validation::check_url_resolvable;
|
||||
|
||||
/// Global redirect-free client with strict TLS validation.
|
||||
static STRICT_CLIENT: Lazy<Client> = Lazy::new(|| {
|
||||
|
|
@ -38,10 +35,6 @@ fn get_client(lax_tls: bool) -> &'static Client {
|
|||
}
|
||||
}
|
||||
|
||||
/// RFC 1918 + loopback + link-local nets we refuse to contact.
|
||||
const BLOCKED_NETS: &[&str] =
|
||||
&["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Aud {
|
||||
|
|
@ -66,11 +59,16 @@ pub struct ValidateOptions {
|
|||
}
|
||||
|
||||
/// Backwards-compatible entry point with secure defaults.
|
||||
pub async fn validate_jwt(token: &str, lax_tls: bool) -> Result<(bool, String)> {
|
||||
pub async fn validate_jwt(
|
||||
token: &str,
|
||||
lax_tls: bool,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<(bool, String)> {
|
||||
validate_jwt_with(
|
||||
token,
|
||||
&ValidateOptions { allow_alg_none: false, fallback_decoding_key: None },
|
||||
lax_tls,
|
||||
allow_internal_ips,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -80,6 +78,7 @@ pub async fn validate_jwt_with(
|
|||
token: &str,
|
||||
opts: &ValidateOptions,
|
||||
lax_tls: bool,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<(bool, String)> {
|
||||
let client = get_client(lax_tls);
|
||||
let claims: Claims = {
|
||||
|
|
@ -197,14 +196,13 @@ pub async fn validate_jwt_with(
|
|||
));
|
||||
}
|
||||
|
||||
for addr in lookup_host((jwks_host.as_str(), 443)).await? {
|
||||
if is_blocked_ip(addr.ip()) {
|
||||
return Ok((false, "jwks_uri resolves to private or link-local IP".to_string()));
|
||||
if let Err(e) = check_url_resolvable(&url, allow_internal_ips).await {
|
||||
if e.downcast_ref::<SsrfBlockedError>().is_some() {
|
||||
return Ok((false, "jwks_uri resolves to non-public or reserved IP".to_string()));
|
||||
}
|
||||
return Err(anyhow!("jwks uri unresolvable: {e}"));
|
||||
}
|
||||
|
||||
check_url_resolvable(&url).await.map_err(|e| anyhow!("jwks uri unresolvable: {e}"))?;
|
||||
|
||||
let jwks_resp = client.get(url).send().await.map_err(|e| anyhow!("jwks fetch failed: {e}"))?;
|
||||
if !jwks_resp.status().is_success() {
|
||||
return Ok((false, format!("jwks fetch failed: {}", jwks_resp.status())));
|
||||
|
|
@ -240,10 +238,6 @@ fn extract_aud_strings(claims: &Claims) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
|
||||
BLOCKED_NETS.iter().filter_map(|cidr| cidr.parse::<IpNet>().ok()).any(|net| net.contains(&ip))
|
||||
}
|
||||
|
||||
fn normalize_issuer_url(issuer: &str) -> Result<Url> {
|
||||
let trimmed = issuer.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
|
|||
|
|
@ -60,10 +60,15 @@ pub use validation_body::{as_str, clone_as_string, from_string, ValidationRespon
|
|||
|
||||
#[cfg(feature = "validation-http")]
|
||||
pub use http_validation::{
|
||||
build_request_builder, check_url_resolvable, generate_http_cache_key_parts, parse_http_method,
|
||||
process_headers, retry_multipart_request, retry_request, validate_response,
|
||||
build_request_builder, check_url_resolvable, generate_http_cache_key_parts, is_ssrf_safe_ip,
|
||||
parse_http_method, process_headers, retry_multipart_request, retry_request, validate_response,
|
||||
SsrfBlockedError,
|
||||
};
|
||||
|
||||
#[cfg(feature = "validation-http")]
|
||||
#[allow(deprecated)]
|
||||
pub use http_validation::check_url_resolvable_safe;
|
||||
|
||||
#[cfg(feature = "validation-aws")]
|
||||
pub use aws::{
|
||||
aws_key_to_account_number, generate_aws_cache_key, revoke_aws_access_key,
|
||||
|
|
|
|||
|
|
@ -986,6 +986,47 @@ The legacy `--ignore-certs` flag is still supported as an alias for `--tls-mode=
|
|||
|
||||
---
|
||||
|
||||
## SSRF Protection
|
||||
|
||||
Kingfisher makes outbound HTTP requests during credential validation, with URLs sometimes constructed from user-controlled data found in scanned content (e.g., domain names extracted alongside API keys). To prevent Server-Side Request Forgery (SSRF), Kingfisher blocks validation requests that would connect to non-public IP addresses.
|
||||
|
||||
### What is blocked
|
||||
|
||||
By default, validation requests are rejected if the target hostname resolves to any of these address ranges:
|
||||
|
||||
| Range | Description |
|
||||
| ----- | ----------- |
|
||||
| `127.0.0.0/8`, `::1` | Loopback (localhost) |
|
||||
| `0.0.0.0/8`, `::` | Unspecified / "this network" (RFC 1122) |
|
||||
| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | Private networks (RFC 1918) |
|
||||
| `169.254.0.0/16`, `fe80::/10` | Link-local (includes cloud metadata at `169.254.169.254`) |
|
||||
| `100.64.0.0/10` | CGNAT / Shared Address Space |
|
||||
| `fc00::/7` | IPv6 unique-local |
|
||||
| `2001:db8::/32` | IPv6 documentation (RFC 3849) |
|
||||
| `::ffff:0:0/96` | IPv4-mapped IPv6 (checked against IPv4 rules) |
|
||||
| `::/96` | IPv4-compatible IPv6 (deprecated) |
|
||||
| `240.0.0.0/4` | Reserved for future use (includes broadcast) |
|
||||
| `fec0::/10` | IPv6 site-local (deprecated, RFC 3879) |
|
||||
| Multicast, benchmarking ranges | Other reserved ranges |
|
||||
|
||||
HTTP redirects during credential validation are also validated: each redirect target is resolved via DNS and checked against the same SSRF rules above. Redirects to non-public IPs are blocked. When `--allow-internal-ips` is used, redirect validation is disabled along with all other SSRF protections.
|
||||
|
||||
### `--allow-internal-ips`
|
||||
|
||||
If you are scanning infrastructure that uses internal endpoints for credential validation (e.g., self-hosted GitLab, Artifactory, or Vault behind a private network), use `--allow-internal-ips` to disable SSRF protections:
|
||||
|
||||
```bash
|
||||
# Scan with SSRF protection disabled (allows requests to internal IPs)
|
||||
kingfisher scan --allow-internal-ips ./repo
|
||||
|
||||
# Also works with the validate command
|
||||
kingfisher validate --allow-internal-ips --rule kingfisher.artifactory.1
|
||||
```
|
||||
|
||||
> **Warning:** Only use `--allow-internal-ips` when you trust the content being scanned. Malicious content could cause Kingfisher to make requests to internal services.
|
||||
|
||||
---
|
||||
|
||||
## Understanding the Scan Summary
|
||||
|
||||
After each scan, Kingfisher displays a summary with validation statistics:
|
||||
|
|
|
|||
|
|
@ -119,6 +119,14 @@ pub struct GlobalArgs {
|
|||
#[arg(global = true, long, value_enum, default_value = "strict")]
|
||||
pub tls_mode: TlsMode,
|
||||
|
||||
/// Allow validation requests to internal/private IP addresses.
|
||||
///
|
||||
/// By default, Kingfisher blocks HTTP requests to loopback, private,
|
||||
/// and link-local addresses during credential validation to prevent SSRF.
|
||||
/// Use this flag when scanning infrastructure that uses internal endpoints.
|
||||
#[arg(global = true, long = "allow-internal-ips", default_value_t = false)]
|
||||
pub allow_internal_ips: bool,
|
||||
|
||||
/// Disable TLS certificate validation (deprecated: use --tls-mode=off)
|
||||
#[arg(global = true, long, hide = true)]
|
||||
pub ignore_certs: bool,
|
||||
|
|
@ -149,6 +157,7 @@ impl Default for GlobalArgs {
|
|||
verbose: 0,
|
||||
quiet: false,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
ignore_certs: false,
|
||||
self_update: false,
|
||||
no_update_check: false,
|
||||
|
|
|
|||
|
|
@ -296,10 +296,16 @@ async fn execute_http_validation(
|
|||
parser: &liquid::Parser,
|
||||
timeout: Duration,
|
||||
retries: u32,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<DirectValidationResult> {
|
||||
// Render the URL
|
||||
let url = render_and_parse_url(parser, globals, &http_validation.request.url).await?;
|
||||
|
||||
// SSRF check: verify the resolved IP is public before making the request
|
||||
crate::validation::utils::check_url_resolvable(&url, allow_internal_ips)
|
||||
.await
|
||||
.map_err(|e| anyhow!("URL resolution failed: {}", e))?;
|
||||
|
||||
debug!("Validating against URL: {}", url);
|
||||
|
||||
// Build the request
|
||||
|
|
@ -351,10 +357,16 @@ async fn execute_grpc_validation(
|
|||
globals: &Object,
|
||||
parser: &liquid::Parser,
|
||||
timeout: Duration,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<DirectValidationResult> {
|
||||
// Render the URL
|
||||
let url = render_and_parse_url(parser, globals, &grpc_validation_cfg.request.url).await?;
|
||||
|
||||
// SSRF check: verify the resolved IP is public before making the request
|
||||
crate::validation::utils::check_url_resolvable(&url, allow_internal_ips)
|
||||
.await
|
||||
.map_err(|e| anyhow!("URL resolution failed: {}", e))?;
|
||||
|
||||
debug!("Validating against gRPC URL: {}", url);
|
||||
|
||||
let res = grpc_validation::grpc_unary_call_from_rule(
|
||||
|
|
@ -438,11 +450,16 @@ pub async fn run_direct_validation(
|
|||
crate::cli::global::TlsMode::Off | crate::cli::global::TlsMode::Lax
|
||||
);
|
||||
|
||||
// Build HTTP client
|
||||
// Build HTTP client with SSRF-safe redirect policy when applicable
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(use_lax_tls)
|
||||
.timeout(Duration::from_secs(args.timeout))
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.redirect(if global_args.allow_internal_ips {
|
||||
reqwest::redirect::Policy::default()
|
||||
} else {
|
||||
crate::validation::ssrf_safe_redirect_policy()
|
||||
})
|
||||
.gzip(true)
|
||||
.deflate(true)
|
||||
.brotli(true)
|
||||
|
|
@ -569,11 +586,19 @@ pub async fn run_direct_validation(
|
|||
&parser,
|
||||
timeout,
|
||||
args.retries,
|
||||
global_args.allow_internal_ips,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Validation::Grpc(grpc_validation_cfg) => {
|
||||
execute_grpc_validation(grpc_validation_cfg, &globals, &parser, timeout).await?
|
||||
execute_grpc_validation(
|
||||
grpc_validation_cfg,
|
||||
&globals,
|
||||
&parser,
|
||||
timeout,
|
||||
global_args.allow_internal_ips,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
Validation::AWS => {
|
||||
|
|
@ -727,7 +752,7 @@ pub async fn run_direct_validation(
|
|||
|
||||
Validation::JWT => {
|
||||
// JWT expects a JWT token as the secret
|
||||
match validate_jwt(&secret, use_lax_tls).await {
|
||||
match validate_jwt(&secret, use_lax_tls, global_args.allow_internal_ips).await {
|
||||
Ok((is_valid, message)) => DirectValidationResult {
|
||||
rule_id: String::new(),
|
||||
rule_name: String::new(),
|
||||
|
|
|
|||
|
|
@ -164,7 +164,10 @@ pub async fn run_async_scan(
|
|||
info!("Starting secret validation phase...");
|
||||
Some(Arc::new((
|
||||
register_all(liquid::ParserBuilder::with_stdlib()).build()?,
|
||||
crate::validation::ValidationClients::new(global_args.tls_mode)?,
|
||||
crate::validation::ValidationClients::new(
|
||||
global_args.tls_mode,
|
||||
global_args.allow_internal_ips,
|
||||
)?,
|
||||
Arc::new(SkipMap::new()),
|
||||
validation_rate_limiter.clone(),
|
||||
)))
|
||||
|
|
|
|||
|
|
@ -118,19 +118,104 @@ pub struct ValidationClients {
|
|||
lax: Client,
|
||||
/// The global TLS mode from CLI arguments.
|
||||
pub global_mode: TlsMode,
|
||||
/// When true, skip SSRF IP validation and allow requests to internal/private addresses.
|
||||
pub allow_internal_ips: bool,
|
||||
}
|
||||
|
||||
/// Build a redirect policy that validates redirect targets against SSRF rules.
|
||||
///
|
||||
/// Each redirect hop is checked: IP-literal targets are validated directly via
|
||||
/// `is_ssrf_safe_ip`, and hostname targets are resolved synchronously via
|
||||
/// `std::net::ToSocketAddrs` so that all resolved IPs can be checked. This
|
||||
/// significantly reduces the hostname-redirect SSRF risk (e.g., a public URL
|
||||
/// that 302s to an attacker-controlled hostname resolving to `169.254.169.254`).
|
||||
/// This is a best-effort check: reqwest performs its own DNS resolution when
|
||||
/// connecting, so a malicious DNS server could return different IPs between
|
||||
/// this check and the actual request (DNS rebinding / TOCTOU). A future
|
||||
/// hardening step would be a pinned/custom resolver so that validated IPs are
|
||||
/// exactly those used for the outbound connection.
|
||||
///
|
||||
/// **Note:** reqwest runs redirect callbacks on Tokio worker threads. The DNS
|
||||
/// lookup uses `tokio::task::block_in_place` so the runtime can compensate
|
||||
/// (e.g., spawn additional worker threads) rather than silently stalling.
|
||||
pub(crate) fn ssrf_safe_redirect_policy() -> reqwest::redirect::Policy {
|
||||
reqwest::redirect::Policy::custom(|attempt| {
|
||||
// Cap redirect depth (reqwest default is 10)
|
||||
if attempt.previous().len() >= 10 {
|
||||
return attempt.error("too many redirects");
|
||||
}
|
||||
// Extract URL info before potentially moving `attempt`.
|
||||
let url = attempt.url().clone();
|
||||
if let Some(host) = url.host_str() {
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
// IP-literal: check directly without DNS.
|
||||
if !kingfisher_scanner::validation::is_ssrf_safe_ip(&ip) {
|
||||
return attempt.error(format!(
|
||||
"SSRF protection: redirect to non-public IP {} blocked",
|
||||
ip
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Hostname: resolve and check all resolved IPs. We use
|
||||
// block_in_place to signal Tokio that this thread is about to
|
||||
// block on synchronous DNS, so the runtime can compensate.
|
||||
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
let dns_result = tokio::task::block_in_place(|| {
|
||||
std::net::ToSocketAddrs::to_socket_addrs(&(host, port))
|
||||
});
|
||||
match dns_result {
|
||||
Ok(addrs) => {
|
||||
for addr in addrs {
|
||||
if !kingfisher_scanner::validation::is_ssrf_safe_ip(&addr.ip()) {
|
||||
return attempt.error(format!(
|
||||
"SSRF protection: redirect to '{}' resolves to non-public IP {} — blocked",
|
||||
host,
|
||||
addr.ip()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Fail closed: if we cannot resolve the hostname, we
|
||||
// cannot guarantee the redirect target is SSRF-safe.
|
||||
return attempt.error(format!(
|
||||
"SSRF protection: cannot resolve redirect host '{}' ({}) — blocked",
|
||||
host, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
attempt.follow()
|
||||
})
|
||||
}
|
||||
|
||||
impl ValidationClients {
|
||||
/// Create validation clients based on the global TLS mode.
|
||||
pub fn new(global_mode: TlsMode) -> anyhow::Result<Self> {
|
||||
pub fn new(global_mode: TlsMode, allow_internal_ips: bool) -> anyhow::Result<Self> {
|
||||
let timeout = std::time::Duration::from_secs(30);
|
||||
|
||||
let strict =
|
||||
Client::builder().danger_accept_invalid_certs(false).timeout(timeout).build()?;
|
||||
let strict = Client::builder()
|
||||
.danger_accept_invalid_certs(false)
|
||||
.redirect(if allow_internal_ips {
|
||||
reqwest::redirect::Policy::default()
|
||||
} else {
|
||||
ssrf_safe_redirect_policy()
|
||||
})
|
||||
.timeout(timeout)
|
||||
.build()?;
|
||||
|
||||
let lax = Client::builder().danger_accept_invalid_certs(true).timeout(timeout).build()?;
|
||||
let lax = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(if allow_internal_ips {
|
||||
reqwest::redirect::Policy::default()
|
||||
} else {
|
||||
ssrf_safe_redirect_policy()
|
||||
})
|
||||
.timeout(timeout)
|
||||
.build()?;
|
||||
|
||||
Ok(Self { strict, lax, global_mode })
|
||||
Ok(Self { strict, lax, global_mode, allow_internal_ips })
|
||||
}
|
||||
|
||||
/// Get the appropriate client for a given rule's TLS mode.
|
||||
|
|
@ -288,6 +373,7 @@ async fn render_and_parse_url(
|
|||
globals: &liquid::Object,
|
||||
rule_name: &str,
|
||||
template_url: &str,
|
||||
allow_internal_ips: bool,
|
||||
) -> Result<Url, String> {
|
||||
let rendered_url_str =
|
||||
render_template(parser, globals, rule_name, template_url).await.map_err(|e| {
|
||||
|
|
@ -302,8 +388,8 @@ async fn render_and_parse_url(
|
|||
error_msg
|
||||
})?;
|
||||
|
||||
// Check if the URL is resolvable.
|
||||
utils::check_url_resolvable(&url).await.map_err(|e| {
|
||||
// Check if the URL is resolvable (with SSRF protection).
|
||||
utils::check_url_resolvable(&url, allow_internal_ips).await.map_err(|e| {
|
||||
let error_msg = format!("URL <{}> resolution failed: {}", &url, e);
|
||||
error_msg
|
||||
})?;
|
||||
|
|
@ -534,6 +620,7 @@ async fn timed_validate_single_match<'a>(
|
|||
&globals,
|
||||
&rule_syntax.name,
|
||||
&http_validation.request.url,
|
||||
clients.allow_internal_ips,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -792,6 +879,7 @@ async fn timed_validate_single_match<'a>(
|
|||
&globals,
|
||||
&rule_syntax.name,
|
||||
&grpc_validation_cfg.request.url,
|
||||
clients.allow_internal_ips,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1168,7 +1256,7 @@ async fn timed_validate_single_match<'a>(
|
|||
return;
|
||||
}
|
||||
|
||||
match jwt::validate_jwt(&token, use_lax_tls).await {
|
||||
match jwt::validate_jwt(&token, use_lax_tls, clients.allow_internal_ips).await {
|
||||
Ok((ok, msg)) => {
|
||||
m.validation_success = ok;
|
||||
m.validation_response_body = validation_body::from_string(msg);
|
||||
|
|
@ -1494,19 +1582,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn validation_clients_new_creates_both_clients() {
|
||||
let clients = ValidationClients::new(TlsMode::Strict).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Strict, false).unwrap();
|
||||
assert_eq!(clients.global_mode, TlsMode::Strict);
|
||||
|
||||
let clients_lax = ValidationClients::new(TlsMode::Lax).unwrap();
|
||||
let clients_lax = ValidationClients::new(TlsMode::Lax, false).unwrap();
|
||||
assert_eq!(clients_lax.global_mode, TlsMode::Lax);
|
||||
|
||||
let clients_off = ValidationClients::new(TlsMode::Off).unwrap();
|
||||
let clients_off = ValidationClients::new(TlsMode::Off, false).unwrap();
|
||||
assert_eq!(clients_off.global_mode, TlsMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_for_rule_strict_mode_always_returns_strict_client() {
|
||||
let clients = ValidationClients::new(TlsMode::Strict).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Strict, false).unwrap();
|
||||
|
||||
// With no rule TLS mode
|
||||
let client1 = clients.client_for_rule(None);
|
||||
|
|
@ -1522,7 +1610,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn client_for_rule_off_mode_always_returns_lax_client() {
|
||||
let clients = ValidationClients::new(TlsMode::Off).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Off, false).unwrap();
|
||||
|
||||
// With no rule TLS mode
|
||||
let client1 = clients.client_for_rule(None);
|
||||
|
|
@ -1538,7 +1626,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn client_for_rule_lax_mode_respects_rule_preference() {
|
||||
let clients = ValidationClients::new(TlsMode::Lax).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Lax, false).unwrap();
|
||||
|
||||
// Get references to understand which is which
|
||||
let strict_client = clients.client_for_rule(None);
|
||||
|
|
@ -1565,7 +1653,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn should_use_lax_off_mode_always_returns_true() {
|
||||
let clients = ValidationClients::new(TlsMode::Off).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Off, false).unwrap();
|
||||
|
||||
assert!(clients.should_use_lax(None));
|
||||
assert!(clients.should_use_lax(Some(kingfisher_rules::TlsMode::Strict)));
|
||||
|
|
@ -1574,7 +1662,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn should_use_lax_strict_mode_always_returns_false() {
|
||||
let clients = ValidationClients::new(TlsMode::Strict).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Strict, false).unwrap();
|
||||
|
||||
assert!(!clients.should_use_lax(None));
|
||||
assert!(!clients.should_use_lax(Some(kingfisher_rules::TlsMode::Strict)));
|
||||
|
|
@ -1583,7 +1671,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn should_use_lax_lax_mode_respects_rule_preference() {
|
||||
let clients = ValidationClients::new(TlsMode::Lax).unwrap();
|
||||
let clients = ValidationClients::new(TlsMode::Lax, false).unwrap();
|
||||
|
||||
// Only true when rule explicitly opts in
|
||||
assert!(!clients.should_use_lax(None));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use reqwest::Url;
|
||||
use tokio::net::lookup_host;
|
||||
|
||||
use crate::validation::SerializableCaptures;
|
||||
|
||||
// Re-export from the scanner crate so the rest of this module can use it.
|
||||
pub use kingfisher_scanner::validation::{check_url_resolvable, is_ssrf_safe_ip};
|
||||
|
||||
/// Return (NAME, value, start, end) for the captures we care about.
|
||||
///
|
||||
/// * Named captures keep their (upper-cased) name
|
||||
|
|
@ -106,13 +106,6 @@ pub fn find_closest_variable(
|
|||
best_before.or(best_overlap).or(best_after).map(|(_, value)| value)
|
||||
}
|
||||
|
||||
pub async fn check_url_resolvable(url: &Url) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let host = url.host_str().ok_or("No host in URL")?;
|
||||
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
let addr = format!("{}:{}", host, port);
|
||||
lookup_host(addr).await?.next().ok_or_else(|| "Failed to resolve URL".into()).map(|_| ())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
@ -122,6 +115,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::matcher::{SerializableCapture, SerializableCaptures};
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::Url;
|
||||
use smallvec::smallvec;
|
||||
|
||||
#[test]
|
||||
|
|
@ -246,4 +240,53 @@ mod tests {
|
|||
|
||||
assert_eq!(result, "after".to_string());
|
||||
}
|
||||
|
||||
// ---- SSRF IP validation tests ----
|
||||
|
||||
#[test]
|
||||
fn ssrf_rejects_loopback() {
|
||||
assert!(!is_ssrf_safe_ip(&"127.0.0.1".parse().unwrap()));
|
||||
assert!(!is_ssrf_safe_ip(&"::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_rejects_unspecified() {
|
||||
assert!(!is_ssrf_safe_ip(&"0.0.0.0".parse().unwrap()));
|
||||
assert!(!is_ssrf_safe_ip(&"::".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_rejects_private_ranges() {
|
||||
assert!(!is_ssrf_safe_ip(&"10.0.0.1".parse().unwrap()));
|
||||
assert!(!is_ssrf_safe_ip(&"172.16.0.1".parse().unwrap()));
|
||||
assert!(!is_ssrf_safe_ip(&"192.168.1.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_rejects_link_local_and_metadata() {
|
||||
assert!(!is_ssrf_safe_ip(&"169.254.169.254".parse().unwrap()));
|
||||
assert!(!is_ssrf_safe_ip(&"169.254.1.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_accepts_public_ips() {
|
||||
assert!(is_ssrf_safe_ip(&"8.8.8.8".parse().unwrap()));
|
||||
assert!(is_ssrf_safe_ip(&"1.1.1.1".parse().unwrap()));
|
||||
assert!(is_ssrf_safe_ip(&"2606:4700::1111".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_url_resolvable_blocks_localhost() {
|
||||
let url = Url::parse("https://localhost/path").unwrap();
|
||||
let result = check_url_resolvable(&url, false).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("SSRF protection"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_url_resolvable_allows_localhost_when_opted_in() {
|
||||
let url = Url::parse("https://localhost/path").unwrap();
|
||||
let result = check_url_resolvable(&url, true).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ fn test_bitbucket_remote_scan() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ rules:
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
// ── load rules once ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ fn test_github_remote_scan() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
// Create in-memory datastore
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ fn test_gitlab_remote_scan() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
@ -362,6 +363,7 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ async fn test_scan_slack_messages() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ async fn test_scan_teams_messages() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: true,
|
||||
};
|
||||
let update_status = UpdateStatus::default();
|
||||
|
||||
|
|
|
|||
|
|
@ -336,6 +336,7 @@ impl TestContext {
|
|||
ignore_certs: false,
|
||||
user_agent_suffix: None,
|
||||
tls_mode: TlsMode::Strict,
|
||||
allow_internal_ips: false,
|
||||
};
|
||||
|
||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue