From dd1528189c79b997e9a27c4e3726d558b3b4a63a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 03:28:48 +0300 Subject: [PATCH] fix(providers): validate Vercel::with_base_url's api root, not just connect_to's Co-authored-by: Medulla --- src/providers/mod.rs | 8 +++++++- src/providers/vercel/mod.rs | 12 +++++++++++- src/providers/vercel/test.rs | 19 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ff2575d..930bdaf 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -187,7 +187,13 @@ pub fn connect_to( /// `localhost`, `127.0.0.1`/`127.x.x.x`, or `::1` — a mock server's or an /// egress proxy's loopback listener never leaves the machine, so plain HTTP /// there costs nothing a caller could intercept. -fn is_secure_base_url(base_url: &str) -> bool { +/// +/// `pub(crate)` because [`vercel::Vercel::with_base_url`] applies the same +/// check: it is a second, lower-level way to reach the same client +/// [`connect_to`] builds, and skipping the check there would just move the +/// cleartext-credential exposure this guards against to a different entry +/// point rather than closing it. +pub(crate) fn is_secure_base_url(base_url: &str) -> bool { if let Some(rest) = base_url.strip_prefix("https://") { return !rest.is_empty(); } diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 2b19bee..8a6f1de 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -70,8 +70,18 @@ impl Vercel { /// /// # Errors /// - /// Returns [`Error::Transport`] when the HTTP client cannot be built. + /// Returns [`Error::InsecureBaseUrl`] when `base_url` is `http://` against a + /// non-loopback host — this constructor sends the bearer credential to + /// every request `base_url` produces, the same as + /// [`connect_to`](crate::providers::connect_to), and applies the same + /// check. Returns [`Error::Transport`] when the HTTP client cannot be + /// built. pub fn with_base_url(credentials: Credentials, base_url: impl Into) -> Result { + let base_url = base_url.into(); + if !crate::providers::is_secure_base_url(&base_url) { + return Err(Error::InsecureBaseUrl { base_url }); + } + Ok(Self { http: Http::new(credentials, base_url)?, }) diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index ef76e55..5ad0651 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -1130,8 +1130,11 @@ async fn a_team_scope_is_applied_to_every_request() { #[test] fn the_client_reports_itself_without_its_token() { - let host = - Vercel::with_base_url(Credentials::new("super-secret").unwrap(), "http://x").unwrap(); + let host = Vercel::with_base_url( + Credentials::new("super-secret").unwrap(), + "http://127.0.0.1:1", + ) + .unwrap(); let rendered = format!("{host:?}"); assert!(!rendered.contains("super-secret"), "{rendered}"); @@ -1139,6 +1142,18 @@ fn the_client_reports_itself_without_its_token() { assert_eq!(host.kind(), ProviderKind::Vercel); } +#[test] +fn with_base_url_rejects_plain_http_against_a_non_loopback_host() { + let error = Vercel::with_base_url(Credentials::new("token").unwrap(), "http://x").unwrap_err(); + + assert_eq!( + error, + Error::InsecureBaseUrl { + base_url: "http://x".to_owned() + } + ); +} + #[test] fn the_digest_is_sha_1() { // The published SHA-1 of "abc". Vercel's `x-vercel-digest` defines the