updated in response to ossf scorecard

This commit is contained in:
Mick Grove 2026-03-27 15:04:14 -07:00
commit 1c7341f3ac
21 changed files with 425 additions and 41 deletions

View file

@ -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. Redirect targets are also validated. 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.

View file

@ -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 }

View file

@ -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;
@ -392,10 +392,220 @@ 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).
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();
// 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;
}
// Documentation ranges (RFC 5737)
if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 {
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;
}
// Broadcast
if octets == [255, 255, 255, 255] {
return false;
}
true
}
IpAddr::V6(v6) => {
let segments = v6.segments();
// Unique local (fc00::/7)
if segments[0] & 0xfe00 == 0xfc00 {
return false;
}
// Link-local (fe80::/10)
if segments[0] & 0xffc0 == 0xfe80 {
return false;
}
true
}
}
}
/// Check if a URL can be resolved via DNS, with SSRF protection against
/// internal/private IP addresses.
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")?;
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(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(())
}
#[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_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_broadcast() {
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 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);
}
}

View file

@ -1,7 +1,6 @@
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,
};
@ -10,7 +9,7 @@ use reqwest::{redirect::Policy, Client, Url};
use serde::Deserialize;
use tokio::net::lookup_host;
use super::http_validation::check_url_resolvable;
use super::http_validation::{check_url_resolvable, is_ssrf_safe_ip};
/// Global redirect-free client with strict TLS validation.
static STRICT_CLIENT: Lazy<Client> = Lazy::new(|| {
@ -38,9 +37,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)]
@ -66,11 +62,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 +81,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,13 +199,17 @@ 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 !allow_internal_ips {
for addr in lookup_host((jwks_host.as_str(), 443)).await? {
if !is_ssrf_safe_ip(&addr.ip()) {
return Ok((false, "jwks_uri resolves to private or link-local IP".to_string()));
}
}
}
check_url_resolvable(&url).await.map_err(|e| anyhow!("jwks uri unresolvable: {e}"))?;
check_url_resolvable(&url, allow_internal_ips)
.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() {
@ -240,9 +246,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();

View file

@ -60,7 +60,8 @@ 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,
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,
};

View file

@ -986,6 +986,42 @@ 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`, `::` | Unspecified |
| `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 |
| Multicast, broadcast, documentation, benchmarking ranges | Other reserved ranges |
HTTP redirects to IP-literal addresses in these ranges are also blocked.
### `--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 validate and revoke commands
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:

View file

@ -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,

View file

@ -727,7 +727,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(),

View file

@ -164,7 +164,7 @@ 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(),
)))

View file

@ -118,19 +118,57 @@ 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 blocks redirects to non-public IP addresses.
fn ssrf_safe_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if let Some(host) = attempt.url().host_str() {
// For IP-literal hosts, check directly without DNS.
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if !kingfisher_scanner::validation::is_ssrf_safe_ip(&ip) {
return attempt.error(format!(
"SSRF protection: redirect to non-public IP {} blocked",
ip
));
}
}
// For hostnames, we cannot do async DNS in the sync redirect
// callback. The pre-request check_url_resolvable call validates
// DNS-resolved IPs before the initial request is made.
}
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 +326,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 +341,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 +573,7 @@ async fn timed_validate_single_match<'a>(
&globals,
&rule_syntax.name,
&http_validation.request.url,
clients.allow_internal_ips,
)
.await
{
@ -792,6 +832,7 @@ async fn timed_validate_single_match<'a>(
&globals,
&rule_syntax.name,
&grpc_validation_cfg.request.url,
clients.allow_internal_ips,
)
.await
{
@ -1168,7 +1209,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 +1535,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 +1563,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 +1579,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 +1606,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 +1615,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 +1624,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));

View file

@ -3,6 +3,9 @@ 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::is_ssrf_safe_ip;
/// Return (NAME, value, start, end) for the captures we care about.
///
/// * Named captures keep their (upper-cased) name
@ -106,11 +109,30 @@ 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>> {
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")?;
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(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(())
}
// -----------------------------------------------------------------------------
@ -246,4 +268,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());
}
}

View file

@ -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)?;

View file

@ -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)));

View file

@ -202,6 +202,7 @@ rules:
ignore_certs: false,
user_agent_suffix: None,
tls_mode: TlsMode::Strict,
allow_internal_ips: false,
};
// ── load rules once ─────────────────────────────────────────────

View file

@ -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)));

View file

@ -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)));

View file

@ -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)?;

View file

@ -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)));

View file

@ -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)?;

View file

@ -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: false,
};
let update_status = UpdateStatus::default();

View file

@ -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)));