From dadf2f6c94c332874b8136396fa2abadafea5d4f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:31:09 +0530 Subject: [PATCH] fix(vercel): refuse empty and dot-only identifiers in URL paths encode_segment leaves dots unescaped, so a site or deployment id of '..' became a dot-segment and addressed the parent route; escaping the dots would not help because the URL parser decodes %2E%2E the same way. An empty id addressed the collection. encode_segment now returns Error::InvalidIdentifier for '', '.' and '..', before any request, and the doc comment no longer claims '..' protection it did not have. --- crates/tinyhosts-bus/src/error/mod.rs | 6 ++++ src/providers/vercel/dot_segment_tests.rs | 40 +++++++++++++++++++++++ src/providers/vercel/mod.rs | 40 +++++++++++++++-------- 3 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 src/providers/vercel/dot_segment_tests.rs diff --git a/crates/tinyhosts-bus/src/error/mod.rs b/crates/tinyhosts-bus/src/error/mod.rs index 137d7ec..0e678a6 100644 --- a/crates/tinyhosts-bus/src/error/mod.rs +++ b/crates/tinyhosts-bus/src/error/mod.rs @@ -48,6 +48,12 @@ pub enum Error { #[error("site name must not be empty")] EmptySiteName, + /// A site name, deployment id or other identifier that goes into a + /// provider URL path was empty, `.` or `..`, which would address a + /// different endpoint than the one named. + #[error("identifier must not be empty, `.` or `..`")] + InvalidIdentifier, + /// A deployment was requested with no files in it. #[error("deployment bundle contains no files")] EmptyBundle, diff --git a/src/providers/vercel/dot_segment_tests.rs b/src/providers/vercel/dot_segment_tests.rs new file mode 100644 index 0000000..9729d58 --- /dev/null +++ b/src/providers/vercel/dot_segment_tests.rs @@ -0,0 +1,40 @@ +//! A dot-only identifier must not reach a Vercel URL path: `..` (or `%2E%2E`, +//! which the URL parser decodes the same way) would address the parent route. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use wiremock::MockServer; + +use super::*; + +fn host(server: &MockServer) -> Vercel { + Vercel::with_base_url(Credentials::new("token").unwrap(), server.uri()).unwrap() +} + +#[tokio::test] +async fn dot_only_identifiers_are_refused_before_any_request() { + let server = MockServer::start().await; + let host = host(&server); + let vars = [EnvVar::new("KEY", "value")]; + + for id in ["", ".", ".."] { + assert_eq!( + host.find_site(id).await.unwrap_err(), + Error::InvalidIdentifier + ); + assert_eq!( + host.set_env(id, &vars).await.unwrap_err(), + Error::InvalidIdentifier + ); + assert_eq!( + host.deployment(id).await.unwrap_err(), + Error::InvalidIdentifier + ); + assert_eq!( + host.list_domains(id).await.unwrap_err(), + Error::InvalidIdentifier + ); + } + + assert!(server.received_requests().await.unwrap().is_empty()); +} diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index a2942c3..6568eeb 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -216,7 +216,7 @@ impl Host for Vercel { async fn find_site(&self, name: &str) -> Result> { let builder = self.http.request( Method::GET, - &format!("/v9/projects/{}", encode_segment(name)), + &format!("/v9/projects/{}", encode_segment(name)?), &[], ); let project: Option = self.http.optional_json(builder, "project").await?; @@ -259,7 +259,7 @@ impl Host for Vercel { self.http .post_discard( - &format!("/v10/projects/{}/env", encode_segment(site)), + &format!("/v10/projects/{}/env", encode_segment(site)?), &[("upsert", "true".to_owned())], &body, "environment variables", @@ -271,7 +271,7 @@ impl Host for Vercel { let envs: Envs = self .http .get_json( - &format!("/v10/projects/{}/env", encode_segment(site)), + &format!("/v10/projects/{}/env", encode_segment(site)?), &[], "environment variables", ) @@ -404,7 +404,7 @@ impl Host for Vercel { let deployment: DeploymentBody = self .http .get_json( - &format!("/v13/deployments/{}", encode_segment(id)), + &format!("/v13/deployments/{}", encode_segment(id)?), &[], "deployment", ) @@ -434,7 +434,7 @@ impl Host for Vercel { let events: DeploymentEvents = self .http .get_json( - &format!("/v3/deployments/{}/events", encode_segment(id)), + &format!("/v3/deployments/{}/events", encode_segment(id)?), &[], "deployment events", ) @@ -454,8 +454,8 @@ impl Host for Vercel { Method::POST, &format!( "/v10/projects/{}/promote/{}", - encode_segment(&project), - encode_segment(deployment) + encode_segment(&project)?, + encode_segment(deployment)? ), &[], ); @@ -472,7 +472,7 @@ impl Host for Vercel { let added: DomainBody = self .http .post_json( - &format!("/v10/projects/{}/domains", encode_segment(site)), + &format!("/v10/projects/{}/domains", encode_segment(site)?), &[], &CreateDomain { name }, "domain", @@ -486,7 +486,7 @@ impl Host for Vercel { let domains: Domains = self .http .get_json( - &format!("/v9/projects/{}/domains", encode_segment(site)), + &format!("/v9/projects/{}/domains", encode_segment(site)?), &[], "domain list", ) @@ -568,8 +568,10 @@ fn env_targets(targets: &[DeploymentTarget]) -> Vec<&'static str> { /// ordinarily made of. /// /// Everything else — `/`, `?`, `#` included — becomes a percent-escape, so a -/// caller-supplied identifier cannot add a path segment, a query string, or a -/// `..` of its own to an authenticated request. +/// caller-supplied identifier cannot add a path segment or a query string to an +/// authenticated request. Dots stay unescaped, and an escaped `%2E%2E` is +/// still a dot-segment once the URL is parsed, so [`encode_segment`] refuses +/// an identifier that is only dots instead. const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC .remove(b'-') .remove(b'.') @@ -577,8 +579,16 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANU .remove(b'~'); /// Percent-encodes `value` for use as one path segment. -fn encode_segment(value: &str) -> impl fmt::Display + '_ { - percent_encoding::utf8_percent_encode(value, PATH_SEGMENT) +/// +/// # Errors +/// +/// Returns [`Error::InvalidIdentifier`] for an empty, `.` or `..` value, each +/// of which would address the parent or the collection instead. +fn encode_segment(value: &str) -> Result { + if matches!(value, "" | "." | "..") { + return Err(Error::InvalidIdentifier); + } + Ok(percent_encoding::utf8_percent_encode(value, PATH_SEGMENT)) } /// The SHA-1 digest of a deployment file, hex encoded, as `x-vercel-digest` @@ -624,3 +634,7 @@ fn buckets(dimension: &str, data: &Value) -> Vec { #[cfg(test)] #[path = "mod_tests.rs"] mod test; + +#[cfg(test)] +#[path = "dot_segment_tests.rs"] +mod dot_segment_tests;