diff --git a/Cargo.lock b/Cargo.lock index 9fe3e56..86d62c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1341,7 +1341,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.7", + "socket2 0.4.10", "tokio", "tower-service", "tracing", @@ -1368,23 +1368,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" -dependencies = [ - "futures-util", - "http 1.1.0", - "hyper 1.4.1", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -1532,9 +1515,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" [[package]] name = "is_terminal_polyfill" @@ -2441,9 +2424,8 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +version = "0.12.2" +source = "git+https://github.com/getsentry/reqwest?branch=restricted-connector#7331a73eb2379c141d65d0cfed4ebcd36927a495" dependencies = [ "base64 0.22.1", "bytes", @@ -2457,7 +2439,6 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.4.1", - "hyper-rustls", "hyper-tls", "hyper-util", "ipnet", @@ -2494,21 +2475,6 @@ dependencies = [ "quick-error", ] -[[package]] -name = "ring" -version = "0.17.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" -dependencies = [ - "cc", - "cfg-if", - "getrandom", - "libc", - "spin", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rmp" version = "0.8.14" @@ -2593,19 +2559,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.23.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pemfile" version = "2.1.2" @@ -2622,17 +2575,6 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" -[[package]] -name = "rustls-webpki" -version = "0.102.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.17" @@ -3171,12 +3113,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - [[package]] name = "string_cache" version = "0.8.7" @@ -3196,12 +3132,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -3226,9 +3156,9 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "1.0.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "system-configuration" @@ -3399,17 +3329,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" -dependencies = [ - "rustls", - "rustls-pki-types", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.15" @@ -3686,12 +3605,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "uptime-checker" version = "0.1.0" @@ -3702,7 +3615,9 @@ dependencies = [ "console", "figment", "futures", + "hickory-resolver", "httpmock", + "ipnet", "metrics", "metrics-exporter-statsd", "redis", @@ -4177,9 +4092,3 @@ dependencies = [ "quote", "syn 2.0.72", ] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index cfd75db..1dcf1e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "uptime-checker" version = "0.1.0" edition = "2021" -rust-version = "1.79" +rust-version = "1.80" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,7 +11,7 @@ anyhow = "1.0.66" clap = { version = "4.4.6", features = ["derive"] } chrono = { version = "0.4.31", default-features = false, features = ["std", "serde"] } httpmock = "0.7.0-rc.1" -reqwest = { version = "0.12.4", features = ["hickory-dns"] } +reqwest = { version = "0.12.2", features = ["hickory-dns"] } rust_arroyo = { version = "*", git = "https://github.com/getsentry/arroyo", rev = "0b84afc07131d8b8d48abcb7c8de8cfa2a98e526" } tokio = { version = "1.28.0", features = ["macros", "sync", "tracing", "signal", "rt-multi-thread", "test-util"] } uuid = { version = "1.8.0", features = ["serde", "v4"] } @@ -35,9 +35,12 @@ metrics = "0.23.0" futures = "0.3.30" tokio-stream = "0.1.15" redis = { version = "0.26.0", features = ["tokio-comp"] } +ipnet = "2.10.0" +hickory-resolver = "0.24.1" [patch.crates-io] rdkafka = { git = "https://github.com/fede1024/rust-rdkafka" } +reqwest = { git = "https://github.com/getsentry/reqwest", branch = "restricted-connector" } [profile.release] lto = true diff --git a/Dockerfile b/Dockerfile index 2502e06..1e83aeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.79-alpine3.20 as builder +FROM rust:1.80-alpine3.20 as builder ARG UPTIME_CHECKER_GIT_REVISION ENV UPTIME_CHECKER_GIT_REVISION=$UPTIME_CHECKER_GIT_REVISION diff --git a/src/checker.rs b/src/checker.rs index d6730c3..71b3a8b 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,5 +1,6 @@ pub mod dummy_checker; pub mod http_checker; +pub mod ip_filter; use std::future::Future; diff --git a/src/checker/http_checker.rs b/src/checker/http_checker.rs index 2f2ada3..9a37858 100644 --- a/src/checker/http_checker.rs +++ b/src/checker/http_checker.rs @@ -12,6 +12,7 @@ use crate::types::{ result::{CheckResult, CheckStatus, CheckStatusReason, CheckStatusReasonType, RequestInfo}, }; +use super::ip_filter::is_external_ip; use super::Checker; const UPTIME_USER_AGENT: &str = @@ -23,6 +24,18 @@ pub struct HttpChecker { client: Client, } +struct Options { + /// When set to true (the default) resolution to internal network addresses will be restricted. + /// This should primarily be disabled for tests. + validate_url: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { validate_url: true } + } +} + /// Fetches the response from a URL. /// /// First attempts to fetch just the head, and if not supported falls back to fetching the entire body. @@ -66,29 +79,34 @@ fn dns_error(err: &reqwest::Error) -> Option { while let Some(source) = inner.source() { inner = source; - // TODO: Would be better to get specific errors without string matching like this - // Not sure if there's a better way - let inner_message = inner.to_string(); - if inner_message.contains("dns error") { - return Some(inner.source().unwrap().to_string()); + if let Some(inner_err) = source.downcast_ref::() { + return Some(format!("{}", inner_err)); } } None } impl HttpChecker { - pub fn new() -> Self { + fn new_internal(options: Options) -> Self { let mut default_headers = HeaderMap::new(); default_headers.insert("User-Agent", UPTIME_USER_AGENT.to_string().parse().unwrap()); - let client = ClientBuilder::new() + let mut builder = ClientBuilder::new() .hickory_dns(true) - .default_headers(default_headers) - .build() - .expect("Failed to build checker client"); + .default_headers(default_headers); + + if options.validate_url { + builder = builder.ip_filter(is_external_ip); + } + + let client = builder.build().expect("Failed to build checker client"); Self { client } } + + pub fn new() -> Self { + Self::new_internal(Default::default()) + } } impl Checker for HttpChecker { @@ -176,7 +194,7 @@ mod tests { use crate::types::result::{CheckStatus, CheckStatusReasonType}; use crate::types::shared::RequestMethod; - use super::{HttpChecker, UPTIME_USER_AGENT}; + use super::{HttpChecker, Options, UPTIME_USER_AGENT}; use chrono::{TimeDelta, Utc}; use httpmock::prelude::*; use httpmock::Method; @@ -188,7 +206,9 @@ mod tests { #[tokio::test] async fn test_default_get() { let server = MockServer::start(); - let checker = HttpChecker::new(); + let checker = HttpChecker::new_internal(Options { + validate_url: false, + }); let get_mock = server.mock(|when, then| { when.method(Method::GET) @@ -218,7 +238,9 @@ mod tests { #[tokio::test] async fn test_configured_post() { let server = MockServer::start(); - let checker = HttpChecker::new(); + let checker = HttpChecker::new_internal(Options { + validate_url: false, + }); let get_mock = server.mock(|when, then| { when.method(Method::POST) @@ -261,7 +283,9 @@ mod tests { let server = MockServer::start(); let timeout = TimeDelta::milliseconds(TIMEOUT); - let checker = HttpChecker::new(); + let checker = HttpChecker::new_internal(Options { + validate_url: false, + }); let timeout_mock = server.mock(|when, then| { when.method(Method::GET) @@ -294,7 +318,9 @@ mod tests { #[tokio::test] async fn test_simple_400() { let server = MockServer::start(); - let checker = HttpChecker::new(); + let checker = HttpChecker::new_internal(Options { + validate_url: false, + }); let head_mock = server.mock(|when, then| { when.method(Method::GET) @@ -328,6 +354,68 @@ mod tests { head_mock.assert(); } + #[tokio::test] + async fn test_restricted_resolution() { + let checker = HttpChecker::new_internal(Options { validate_url: true }); + + let localhost_config = CheckConfig { + url: "http://localhost/whatever".to_string(), + ..Default::default() + }; + + let tick = make_tick(); + let result = checker.check_url(&localhost_config, &tick).await; + + assert_eq!(result.status, CheckStatus::Failure); + assert_eq!(result.request_info.and_then(|i| i.http_status_code), None); + + assert_eq!( + result.status_reason.as_ref().map(|r| r.status_type), + Some(CheckStatusReasonType::DnsError) + ); + assert_eq!( + result.status_reason.map(|r| r.description), + Some("destination is restricted".to_string()) + ); + } + + #[tokio::test] + async fn test_validate_url() { + let checker = HttpChecker::new_internal(Options { validate_url: true }); + + // Private address space + let restricted_ip_config = CheckConfig { + url: "http://10.0.0.1/".to_string(), + ..Default::default() + }; + let tick = make_tick(); + let result = checker.check_url(&restricted_ip_config, &tick).await; + + assert_eq!(result.status, CheckStatus::Failure); + assert_eq!(result.request_info.and_then(|i| i.http_status_code), None); + + assert_eq!( + result.status_reason.as_ref().map(|r| r.status_type), + Some(CheckStatusReasonType::DnsError) + ); + assert_eq!( + result.status_reason.map(|r| r.description), + Some("destination is restricted".to_string()) + ); + + // Unique Local Address + let restricted_ipv6_config = CheckConfig { + url: "http://[fd12:3456:789a:1::1]/".to_string(), + ..Default::default() + }; + let tick = make_tick(); + let result = checker.check_url(&restricted_ipv6_config, &tick).await; + assert_eq!( + result.status_reason.map(|r| r.description), + Some("destination is restricted".to_string()) + ); + } + // TODO: Figure out how to simulate a DNS failure // assert_eq!(check_domain(&client, "https://hjkhjkljkh.io/".to_string()).await, CheckResult::FAILURE(FailureReason::DnsError("failed to lookup address information: nodename nor servname provided, or not known".to_string()))); } diff --git a/src/checker/ip_filter.rs b/src/checker/ip_filter.rs new file mode 100644 index 0000000..6b5cb29 --- /dev/null +++ b/src/checker/ip_filter.rs @@ -0,0 +1,65 @@ +use ipnet::IpNet; +use std::net::IpAddr; +use std::sync::LazyLock; + +pub static PRIVATE_RANGES: LazyLock> = LazyLock::new(|| { + let addresses = vec![ + // https://en.wikipedia.org/wiki/Reserved_IP_addresses#IPv4 + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/29", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "255.255.255.255/32", + // https://en.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses + // Subnets match the IPv4 subnets above + "::ffff:0:0/104", + "::ffff:a00:0/104", + "::ffff:6440:0/106", + "::ffff:7f00:0/104", + "::ffff:a9fe:0/112", + "::ffff:ac10:0/108", + "::ffff:c000:0/125", + "::ffff:c000:200/120", + "::ffff:c058:6300/120", + "::ffff:c0a8:0/112", + "::ffff:c612:0/111", + "::ffff:c633:6400/120", + "::ffff:e000:0/100", + "::ffff:f000:0/100", + "::ffff:ffff:ffff/128", + // https://en.wikipedia.org/wiki/Reserved_IP_addresses#IPv6 + "::1/128", + "::ffff:0:0:0/96", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "2001:0000::/32", + "2001:20::/28", + "2001:db8::/32", + "2002::/16", + "fc00::/7", + "fe80::/10", + "ff00::/8", + ]; + + addresses.iter().map(|addr| addr.parse().unwrap()).collect() +}); + +pub fn is_external_ip(ip: IpAddr) -> bool { + if PRIVATE_RANGES.iter().any(|network| network.contains(&ip)) { + tracing::debug!("Blocked attempt to connect to reserved IP address: {}", ip); + true + } else { + false + } +}