Skip to content

Commit 4223621

Browse files
committed
fix(pm): harden private registry configuration
1 parent 5cb7cce commit 4223621

3 files changed

Lines changed: 443 additions & 99 deletions

File tree

‎crates/vp_pm_cli/src/config.rs‎

Lines changed: 201 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
use std::{collections::HashMap, env, fs, path::PathBuf};
1+
use std::{collections::HashMap, env, ffi::OsString, fs, path::PathBuf};
22

33
use cow_utils::CowUtils;
44
use reqwest::{RequestBuilder, Url};
55
use vp_shared::EnvConfig;
6+
use vt_path::AbsolutePath;
67
use vt_workspace::find_workspace_root;
78

89
const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmjs.org";
@@ -16,11 +17,20 @@ pub(crate) struct NpmConfig {
1617

1718
impl NpmConfig {
1819
pub(crate) fn load() -> Self {
19-
let project_root = vt_path::current_dir()
20+
vt_path::current_dir()
2021
.ok()
21-
.and_then(|cwd| find_workspace_root(&cwd).ok())
22-
.map(|(root, _)| root.path.as_path().to_path_buf());
23-
Self::load_for_project(project_root)
22+
.map_or_else(|| Self::load_for_project(None), |cwd| Self::load_for_cwd(&cwd))
23+
}
24+
25+
pub(crate) fn load_for_cwd(cwd: &AbsolutePath) -> Self {
26+
find_workspace_root(cwd).map_or_else(
27+
|_| Self::load_for_project(None),
28+
|(root, _)| Self::load_for_project_root(&root.path),
29+
)
30+
}
31+
32+
pub(crate) fn load_for_project_root(project_root: &AbsolutePath) -> Self {
33+
Self::load_for_project(Some(project_root.as_path().to_path_buf()))
2434
}
2535

2636
fn load_for_project(project_root: Option<PathBuf>) -> Self {
@@ -40,12 +50,8 @@ impl NpmConfig {
4050
}
4151

4252
// npm_config_* is the highest-precedence npm config source available to vp.
43-
for (key, value) in env::vars() {
44-
let Some(raw_key) =
45-
key.strip_prefix("npm_config_").or_else(|| key.strip_prefix("NPM_CONFIG_"))
46-
else {
47-
continue;
48-
};
53+
for (key, value) in npm_config_env() {
54+
let raw_key = &key["npm_config_".len()..];
4955
if value.is_empty() {
5056
continue;
5157
}
@@ -60,15 +66,34 @@ impl NpmConfig {
6066
Self { values }
6167
}
6268

63-
fn registry_for_package(&self, package: &str) -> String {
69+
pub(crate) fn registry_for_package(&self, package: &str) -> String {
6470
let scoped = package
6571
.strip_prefix('@')
6672
.and_then(|rest| rest.split_once('/'))
67-
.and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str()));
68-
scoped.or_else(|| self.values.get("registry")).map_or_else(
69-
|| DEFAULT_NPM_REGISTRY.to_string(),
70-
|value| value.trim_end_matches('/').to_string(),
71-
)
73+
.and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str()))
74+
.filter(|value| !value.is_empty());
75+
scoped
76+
.or_else(|| self.values.get("registry").filter(|value| !value.is_empty()))
77+
.map_or_else(
78+
|| DEFAULT_NPM_REGISTRY.to_string(),
79+
|value| value.trim_end_matches('/').to_string(),
80+
)
81+
}
82+
83+
pub(crate) fn package_tgz_url(&self, name: &str, version: &str) -> vt_str::Str {
84+
let registry = self.registry_for_package(name);
85+
let filename = name.split('/').next_back().unwrap_or(name);
86+
vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz")
87+
}
88+
89+
pub(crate) fn package_version_url(&self, name: &str, version_or_tag: &str) -> vt_str::Str {
90+
let registry = self.registry_for_package(name);
91+
vt_str::format!("{registry}/{name}/{version_or_tag}")
92+
}
93+
94+
pub(crate) fn package_metadata_url(&self, name: &str) -> vt_str::Str {
95+
let registry = self.registry_for_package(name);
96+
vt_str::format!("{registry}/{name}")
7297
}
7398

7499
pub(crate) fn apply_auth(&self, request: RequestBuilder, url: &str) -> RequestBuilder {
@@ -95,10 +120,13 @@ impl NpmConfig {
95120
for prefix in [prefix.as_str(), prefix.trim_end_matches('/')] {
96121
if let Some(token) =
97122
self.values.get(vt_str::format!("{prefix}:_authtoken").as_str())
123+
&& !token.is_empty()
98124
{
99125
return request.bearer_auth(token);
100126
}
101-
if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str()) {
127+
if let Some(auth) = self.values.get(vt_str::format!("{prefix}:_auth").as_str())
128+
&& !auth.is_empty()
129+
{
102130
return request.header(
103131
reqwest::header::AUTHORIZATION,
104132
vt_str::format!("Basic {auth}").as_str(),
@@ -107,6 +135,8 @@ impl NpmConfig {
107135
let username = self.values.get(vt_str::format!("{prefix}:username").as_str());
108136
let password = self.values.get(vt_str::format!("{prefix}:_password").as_str());
109137
if let (Some(username), Some(password)) = (username, password)
138+
&& !username.is_empty()
139+
&& !password.is_empty()
110140
&& let Ok(decoded) = base64_simd::STANDARD.decode_to_vec(password)
111141
{
112142
return request
@@ -119,11 +149,25 @@ impl NpmConfig {
119149
}
120150

121151
fn env_value(name: &str) -> Option<String> {
122-
env::vars().find_map(|(key, value)| {
123-
key.strip_prefix("npm_config_")
124-
.or_else(|| key.strip_prefix("NPM_CONFIG_"))
125-
.filter(|key| key.eq_ignore_ascii_case(name))
126-
.map(|_| value)
152+
npm_config_env().find_map(|(key, value)| {
153+
key["npm_config_".len()..]
154+
.eq_ignore_ascii_case(name)
155+
.then_some(value)
156+
.filter(|value| !value.is_empty())
157+
})
158+
}
159+
160+
fn npm_config_env() -> impl Iterator<Item = (String, String)> {
161+
npm_config_env_from(env::vars_os())
162+
}
163+
164+
fn npm_config_env_from(
165+
vars: impl Iterator<Item = (OsString, OsString)>,
166+
) -> impl Iterator<Item = (String, String)> {
167+
vars.filter_map(|(key, value)| {
168+
let key = key.into_string().ok()?;
169+
let value = value.into_string().ok()?;
170+
key.get(.."npm_config_".len())?.eq_ignore_ascii_case("npm_config_").then_some((key, value))
127171
})
128172
}
129173

@@ -183,38 +227,51 @@ fn load_npmrc(path: PathBuf, values: &mut HashMap<String, String>) {
183227
let Some((key, value)) = line.split_once('=') else { continue };
184228
let key = normalize_key(key);
185229
if !key.is_empty() {
186-
values.insert(key, expand_value(value));
230+
values.insert(key, expand_value(&parse_npmrc_value(value)));
187231
}
188232
}
189233
}
190234

191-
/// Get the configured default NPM registry URL.
192-
#[must_use]
193-
pub fn npm_registry() -> String {
194-
NpmConfig::load().registry_for_package("")
195-
}
196-
197-
fn npm_registry_for_package(name: &str) -> String {
198-
NpmConfig::load().registry_for_package(name)
199-
}
200-
201-
#[must_use]
202-
pub(crate) fn get_npm_package_tgz_url(name: &str, version: &str) -> vt_str::Str {
203-
let registry = npm_registry_for_package(name);
204-
let filename = name.split('/').next_back().unwrap_or(name);
205-
vt_str::format!("{registry}/{name}/-/{filename}-{version}.tgz")
206-
}
235+
fn parse_npmrc_value(value: &str) -> String {
236+
let value = value.trim();
237+
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
238+
return serde_json::from_str(value)
239+
.unwrap_or_else(|_| value[1..value.len() - 1].to_string());
240+
}
241+
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
242+
return value[1..value.len() - 1].to_string();
243+
}
207244

208-
#[must_use]
209-
pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vt_str::Str {
210-
let registry = npm_registry_for_package(name);
211-
vt_str::format!("{registry}/{name}/{version_or_tag}")
245+
let mut parsed = String::with_capacity(value.len());
246+
let mut escaped = false;
247+
for character in value.chars() {
248+
if escaped {
249+
if !matches!(character, '\\' | '#' | ';') {
250+
parsed.push('\\');
251+
}
252+
parsed.push(character);
253+
escaped = false;
254+
continue;
255+
}
256+
if character == '\\' {
257+
escaped = true;
258+
continue;
259+
}
260+
if matches!(character, '#' | ';') {
261+
break;
262+
}
263+
parsed.push(character);
264+
}
265+
if escaped {
266+
parsed.push('\\');
267+
}
268+
parsed.trim_end().to_string()
212269
}
213270

271+
/// Get the configured default NPM registry URL.
214272
#[must_use]
215-
pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str {
216-
let registry = npm_registry_for_package(name);
217-
vt_str::format!("{registry}/{name}")
273+
pub fn npm_registry() -> String {
274+
NpmConfig::load().registry_for_package("")
218275
}
219276

220277
#[cfg(test)]
@@ -248,6 +305,68 @@ mod tests {
248305
});
249306
}
250307

308+
#[test]
309+
fn loads_registry_from_caller_provided_workspace() {
310+
let project = project_with_npmrc("registry=https://target.example\n");
311+
let cwd = AbsolutePath::new(project.path()).unwrap();
312+
EnvConfig::with_vars(std::iter::empty::<(&str, &str)>(), |_| {
313+
let config = NpmConfig::load_for_cwd(cwd);
314+
assert_eq!(config.registry_for_package("pnpm"), "https://target.example");
315+
});
316+
}
317+
318+
#[test]
319+
fn empty_registry_values_fall_back() {
320+
let config = NpmConfig {
321+
values: HashMap::from([
322+
("@yarnpkg:registry".to_string(), String::new()),
323+
("registry".to_string(), "https://default.example/".to_string()),
324+
]),
325+
};
326+
assert_eq!(config.registry_for_package("@yarnpkg/cli-dist"), "https://default.example");
327+
328+
let config = NpmConfig { values: HashMap::from([("registry".to_string(), String::new())]) };
329+
assert_eq!(config.registry_for_package("pnpm"), DEFAULT_NPM_REGISTRY);
330+
}
331+
332+
#[test]
333+
fn empty_userconfig_environment_value_is_ignored() {
334+
EnvConfig::with_vars([("NPM_CONFIG_USERCONFIG", "")], |_| {
335+
assert_eq!(env_value("userconfig"), None);
336+
});
337+
}
338+
339+
#[test]
340+
fn npm_config_environment_prefix_is_case_insensitive() {
341+
let values = npm_config_env_from(
342+
[(OsString::from("Npm_Config_Registry"), OsString::from("https://example.test"))]
343+
.into_iter(),
344+
)
345+
.collect::<Vec<_>>();
346+
assert_eq!(
347+
values,
348+
vec![("Npm_Config_Registry".to_string(), "https://example.test".to_string())]
349+
);
350+
}
351+
352+
#[cfg(unix)]
353+
#[test]
354+
fn non_unicode_environment_entries_are_skipped() {
355+
use std::os::unix::ffi::OsStringExt;
356+
357+
let values = npm_config_env_from(
358+
[
359+
(OsString::from_vec(vec![0xff]), OsString::from("ignored")),
360+
(OsString::from("NPM_CONFIG_REGISTRY"), OsString::from_vec(vec![0xff])),
361+
(OsString::from("NPM_CONFIG_REGISTRY"), OsString::from("https://example.test")),
362+
]
363+
.into_iter(),
364+
)
365+
.collect::<Vec<_>>();
366+
assert_eq!(values.len(), 1);
367+
assert_eq!(values[0].1, "https://example.test");
368+
}
369+
251370
#[test]
252371
fn environment_registry_overrides_project() {
253372
let project = project_with_npmrc("registry=https://project.example\n");
@@ -274,6 +393,41 @@ mod tests {
274393
});
275394
}
276395

396+
#[test]
397+
fn empty_credentials_fall_back_to_parent_auth_path() {
398+
let config = NpmConfig {
399+
values: HashMap::from([
400+
("//registry.example/team/:_authtoken".to_string(), String::new()),
401+
("//registry.example/:_authtoken".to_string(), "HOST".to_string()),
402+
]),
403+
};
404+
let request = config
405+
.apply_auth(
406+
http_client().get("https://registry.example/team/pkg"),
407+
"https://registry.example/team/pkg",
408+
)
409+
.build()
410+
.unwrap();
411+
assert_eq!(request.headers()[reqwest::header::AUTHORIZATION], "Bearer HOST");
412+
}
413+
414+
#[test]
415+
fn parses_inline_comments_and_escapes_in_npmrc_values() {
416+
let project = project_with_npmrc(
417+
"registry=https://registry.example/ ; mirror\n\
418+
//registry.example/:_authToken=SECRET # CI\n\
419+
quoted=\"value # retained\"\n\
420+
fragment=https://example.test/\\#retained\n\
421+
semicolon=left\\;right ; removed\n",
422+
);
423+
let config = NpmConfig::load_for_project(Some(project.path().to_path_buf()));
424+
assert_eq!(config.registry_for_package("pnpm"), "https://registry.example");
425+
assert_eq!(config.values["//registry.example/:_authtoken"], "SECRET");
426+
assert_eq!(config.values["quoted"], "value # retained");
427+
assert_eq!(config.values["fragment"], "https://example.test/#retained");
428+
assert_eq!(config.values["semicolon"], "left;right");
429+
}
430+
277431
#[test]
278432
fn does_not_send_auth_to_another_host() {
279433
let config = NpmConfig {

0 commit comments

Comments
 (0)