Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/tinyhosts-bus/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions src/providers/vercel/dot_segment_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
40 changes: 27 additions & 13 deletions src/providers/vercel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ impl Host for Vercel {
async fn find_site(&self, name: &str) -> Result<Option<Site>> {
let builder = self.http.request(
Method::GET,
&format!("/v9/projects/{}", encode_segment(name)),
&format!("/v9/projects/{}", encode_segment(name)?),
&[],
);
let project: Option<Project> = self.http.optional_json(builder, "project").await?;
Expand Down Expand Up @@ -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",
Expand All @@ -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",
)
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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",
)
Expand All @@ -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)?
),
&[],
);
Expand All @@ -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",
Expand All @@ -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",
)
Expand Down Expand Up @@ -568,17 +568,27 @@ 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'.')
.remove(b'_')
.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<impl fmt::Display + '_> {
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`
Expand Down Expand Up @@ -624,3 +634,7 @@ fn buckets(dimension: &str, data: &Value) -> Vec<AnalyticsBucket> {
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;

#[cfg(test)]
#[path = "dot_segment_tests.rs"]
mod dot_segment_tests;
Loading