denoland/deno · #36474

fix(publish): reject invalid JSR package names

nathanwhit · merged Aug 25, 20265 files · 253 + / 43
cli/registry.rs161 + / 24
@@ -5,6 +5,7 @@ use deno_core::error::AnyError; use deno_core::serde_json; use deno_core::url::Url; use deno_runtime::deno_fetch;+use deno_semver::jsr::JsrPackageReqReference; use serde::de::DeserializeOwned;  use crate::http_util;@@ -134,28 +135,67 @@ pub fn get_package_api_url(   registry_api_url: &Url,   scope: &str,   package: &str,-) -> String {-  format!("{}scopes/{}/packages/{}", registry_api_url, scope, package)+) -> Result<Url, AnyError> {+  append_path_segments(+    registry_api_url,+    &["scopes", scope, "packages", package],+  ) }  pub fn get_package_version_api_url(   registry_api_url: &Url,   scope: &str,   package: &str,   version: &str,-  params: Option<&str>,-) -> String {-  if let Some(params) = params {-    format!(-      "{}scopes/{}/packages/{}/versions/{}?{}",-      registry_api_url, scope, package, version, params-    )-  } else {-    format!(-      "{}scopes/{}/packages/{}/versions/{}",-      registry_api_url, scope, package, version-    )+  config_path: Option<&str>,+) -> Result<Url, AnyError> {+  let mut url = append_path_segments(+    registry_api_url,+    &["scopes", scope, "packages", package, "versions", version],+  )?;+  if let Some(config_path) = config_path {+    url.query_pairs_mut().append_pair("config", config_path);   }+  Ok(url)+}++pub fn get_package_version_provenance_api_url(+  registry_api_url: &Url,+  scope: &str,+  package: &str,+  version: &str,+) -> Result<Url, AnyError> {+  append_path_segments(+    registry_api_url,+    &[+      "scopes",+      scope,+      "packages",+      package,+      "versions",+      version,+      "provenance",+    ],+  )+}++fn append_path_segments(+  base_url: &Url,+  segments: &[&str],+) -> Result<Url, AnyError> {+  let mut url = base_url.clone();+  url.set_query(None);+  url.set_fragment(None);+  url+    .path_segments_mut()+    .map_err(|_| {+      deno_core::anyhow::anyhow!(+        "Registry API URL cannot be used as a base URL"+      )+    })?+    .pop_if_empty()+    .extend(segments);+  Ok(url) }  pub async fn get_package(@@ -164,20 +204,41 @@ pub async fn get_package(   scope: &str,   package: &str, ) -> Result<http::Response<deno_fetch::ResBody>, AnyError> {-  let package_url = get_package_api_url(registry_api_url, scope, package);-  let response = client.get(package_url.parse()?)?.send().await?;+  let package_url = get_package_api_url(registry_api_url, scope, package)?;+  let response = client.get(package_url)?.send().await?;   Ok(response) }  /// Splits a fully qualified JSR package name (e.g. `@scope/package`) into its /// `(scope, package)` parts. pub fn parse_package_name(name: &str) -> Result<(&str, &str), AnyError> {-  let Some((scope, package)) = name-    .strip_prefix('@')-    .and_then(|no_at| no_at.split_once('/'))+  // Keep the explicit path-safety checks below even if the JSR grammar changes.+  let reference = JsrPackageReqReference::from_str(&format!("jsr:{name}@*"))+    .map_err(|_| {+      deno_core::anyhow::anyhow!(+        "package name must use the '@<scope>/<package>' format"+      )+    })?;+  if reference.sub_path().is_some() {+    bail!("package name must not contain additional path segments");+  }++  let Some((scope, package)) =+    name.strip_prefix('@').and_then(|name| name.split_once('/'))   else {-    bail!("Invalid package name, use '@<scope_name>/<package_name>' format");+    bail!("package name must use the '@<scope>/<package>' format");   };+  for component in [scope, package] {+    if component == "." || component == ".." {+      bail!("package name must not contain dot path segments");+    }+    if component+      .chars()+      .any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%'))+    {+      bail!("package name contains a URL path or delimiter character");+    }+  }   Ok((scope, package)) } @@ -201,8 +262,8 @@ pub async fn check_version_exists(     package,     version,     None,-  );-  let response = client.get(url.parse()?)?.send().await?;+  )?;+  let response = client.get(url)?.send().await?;   Ok(response.status() == 200) } @@ -269,8 +330,84 @@ mod test {   #[test]   fn test_parse_package_name() {     assert_eq!(parse_package_name("@deno/doc").unwrap(), ("deno", "doc"));-    assert!(parse_package_name("deno/doc").is_err());-    assert!(parse_package_name("@deno").is_err());+    assert_eq!(+      parse_package_name("@scope-1/package-2").unwrap(),+      ("scope-1", "package-2")+    );+    assert_eq!(parse_package_name("@a/b").unwrap(), ("a", "b"));++    for invalid_name in [+      "deno/doc",+      "@deno",+      "@deno/../doc",+      "@deno/doc/other",+      "@deno/doc?other",+      "@deno/doc#other",+      "@deno/doc\\other",+      "@deno/doc%2Fother",+    ] {+      assert!(+        parse_package_name(invalid_name).is_err(),+        "{invalid_name} should be invalid"+      );+    }+  }++  #[test]+  fn package_api_urls_use_path_segments() {+    let base = Url::parse("https://registry.example/custom/api/").unwrap();+    let package_url = get_package_api_url(&base, "scope", "package").unwrap();+    assert_eq!(+      package_url.as_str(),+      "https://registry.example/custom/api/scopes/scope/packages/package"+    );++    let version_url = get_package_version_api_url(+      &base,+      "scope",+      "package",+      "1.2.3+build.1",+      Some("/deno.json"),+    )+    .unwrap();+    assert_eq!(+      version_url.path(),+      "/custom/api/scopes/scope/packages/package/versions/1.2.3+build.1"+    );+    assert_eq!(+      version_url.query_pairs().collect::<Vec<_>>(),+      vec![("config".into(), "/deno.json".into())]+    );++    let provenance_url = get_package_version_provenance_api_url(+      &base, "scope", "package", "1.2.3",+    )+    .unwrap();+    assert_eq!(+      provenance_url.as_str(),+      "https://registry.example/custom/api/scopes/scope/packages/package/versions/1.2.3/provenance"+    );+  }++  #[test]+  fn package_api_url_components_cannot_change_the_endpoint() {+    let base = Url::parse("https://registry.example/custom/api/").unwrap();+    let url = get_package_version_api_url(+      &base,+      "scope/../../other",+      "package?mode=other#fragment",+      "1.0.0/../../other",+      None,+    )+    .unwrap();+    assert_eq!(+      url.origin().ascii_serialization(),+      "https://registry.example"+    );+    assert_eq!(+      url.as_str(),+      "https://registry.example/custom/api/scopes/scope%2F..%2F..%2Fother/packages/package%3Fmode=other%23fragment/versions/1.0.0%2F..%2F..%2Fother"+    );   }    #[test]
cli/tools/publish/mod.rs48 + / 14
@@ -28,6 +28,7 @@ use deno_core::serde_json::json; use deno_core::url::Url; use deno_resolver::collections::FolderScopedMap; use deno_runtime::deno_fetch;+use deno_semver::Version; use deno_terminal::colors; use http_body_util::BodyExt; use serde::Deserialize;@@ -80,9 +81,6 @@ pub async fn publish( ) -> Result<(), AnyError> {   let cli_factory = CliFactory::from_flags(flags); -  let auth_method =-    get_auth_method(publish_flags.token, publish_flags.dry_run)?;-   let cli_options = cli_factory.cli_options()?;   let directory_path = cli_options.initial_cwd();   let mut publish_configs = cli_options.start_dir.jsr_packages_for_publish();@@ -123,6 +121,11 @@ pub async fn publish(     }   } +  validate_publish_configs(&publish_configs)?;++  let auth_method =+    get_auth_method(publish_flags.token, publish_flags.dry_run)?;+   // Bail out early if the version is already published, before doing the   // expensive type checking and tarball preparation. Already-published   // versions are skipped with a warning (rather than erroring) so that@@ -226,6 +229,34 @@ pub async fn publish(   Ok(()) } +fn validate_publish_configs(+  publish_configs: &[JsrPackageConfig],+) -> Result<(), AnyError> {+  for config in publish_configs {+    registry::parse_package_name(&config.name).with_context(|| {+      format!(+        "Invalid package name '{}' in '{}'",+        config.name, config.config_file.specifier+      )+    })?;++    let version =+      config.config_file.json.version.as_deref().ok_or_else(|| {+        deno_core::anyhow::anyhow!(+          "{} is missing 'version' field",+          config.config_file.specifier+        )+      })?;+    Version::parse_standard(version).with_context(|| {+      format!(+        "Invalid package version '{}' in '{}'",+        version, config.config_file.specifier+      )+    })?;+  }+  Ok(())+}+ /// Queries the registry for each package's version concurrently and returns the /// subset of configs whose versions are not yet published. Already-published /// versions are reported with a warning and dropped from the returned list.@@ -865,11 +896,11 @@ async fn ensure_scopes_and_packages_exist(       registry_api_url,       &create_package_info.scope,       &create_package_info.package,-    );+    )?;      loop {       tokio::time::sleep(std::time::Duration::from_secs(3)).await;-      let response = client.get(package_api_url.parse()?)?.send().await?;+      let response = client.get(package_api_url.clone())?.send().await?;       if response.status() == 200 {         let name = format!(           "@{}/{}",@@ -1043,17 +1074,18 @@ async fn publish_package(     package.version   ); +  let config_path = format!("/{}", package.config);   let url = registry::get_package_version_api_url(     registry_api_url,     &package.scope,     &package.package,     &package.version,-    Some(&format!("config=/{}", package.config)),-  );+    Some(&config_path),+  )?;    let body = deno_fetch::ReqBody::full(package.tarball.bytes.clone());   let response = http_client-    .post(url.parse()?, body)?+    .post(url, body)?     .header(       http::header::AUTHORIZATION,       authorization.parse().map_err(http::Error::from)?,@@ -1179,10 +1211,12 @@ async fn publish_package(       format!("https://search.sigstore.dev/?logIndex={log_index}");      // Submit bundle to JSR-    let provenance_url = format!(-      "{}scopes/{}/packages/{}/versions/{}/provenance",-      registry_api_url, package.scope, package.package, package.version-    );+    let provenance_url = registry::get_package_version_provenance_api_url(+      registry_api_url,+      &package.scope,+      &package.package,+      &package.version,+    )?;     match submit_provenance_bundle(       http_client,       &provenance_url,@@ -1335,12 +1369,12 @@ struct VersionManifest { /// provenance badge. That is how jsr-io/jsr#1474 went unnoticed for a month. async fn submit_provenance_bundle(   http_client: &HttpClient,-  provenance_url: &str,+  provenance_url: &Url,   authorization: &str,   bundle: &provenance::ProvenanceBundle, ) -> Result<(), AnyError> {   let response = http_client-    .post_json(provenance_url.parse()?, &json!({ "bundle": bundle }))?+    .post_json(provenance_url.clone(), &json!({ "bundle": bundle }))?     .header(http::header::AUTHORIZATION, authorization.parse()?)     .send()     .await?;
tests/integration/publish_tests.rs37 + / 0
@@ -77,6 +77,43 @@ fn publish_warning_not_in_graph() {     ); } +#[test]+fn publish_rejects_invalid_identities_before_authentication() {+  let context = TestContextBuilder::new().use_temp_cwd().build();+  let temp_dir = context.temp_dir().path();+  temp_dir.join("LICENSE").write("");+  temp_dir.join("mod.ts").write("export const value = 1;");++  for name in [+    "@foo/../../other",+    "@foo/package/other",+    "@foo/package?other",+    "@foo/package#other",+    "@foo/package\\other",+  ] {+    temp_dir.join("deno.json").write_json(&json!({+      "name": name,+      "version": "1.0.0",+      "exports": "./mod.ts",+    }));++    let output = context.new_command().arg("publish").run();+    output.assert_exit_code(1);+    assert_contains!(output.combined_output(), "Invalid package name");+    assert_not_contains!(output.combined_output(), "No means to authenticate");+  }++  temp_dir.join("deno.json").write_json(&json!({+    "name": "@foo/package",+    "version": "1.0.0?other",+    "exports": "./mod.ts",+  }));+  let output = context.new_command().arg("publish").run();+  output.assert_exit_code(1);+  assert_contains!(output.combined_output(), "Invalid package version");+  assert_not_contains!(output.combined_output(), "No means to authenticate");+}+ #[test] fn provenance() {   TestContextBuilder::new()
tests/specs/init/lib/dry_publish.out2 + / 5
@@ -1,7 +1,4 @@-Check [WILDCARD]mod.ts-Checking for slow types in the public API...-Check [WILDCARD]mod.ts-error: Failed preparing 'project'.+error: Invalid package name 'project' in 'file:///[WILDCARD]/project/deno.json'  Caused by:-    Invalid package name, use '@<scope_name>/<package_name>' format+    package name must use the '@<scope>/<package>' format
tests/specs/publish/no_token/deno.jsonadded5 + / 0
@@ -0,0 +1,5 @@+{+  "name": "@deno/no-token",+  "version": "1.0.0",+  "exports": "./mod.ts"+}