mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-18 03:32:36 -04:00
fix(pnpr): make user self-registration opt-in by default (#12581)
pnpr allowed anonymous self-registration by default. An omitted `auth.htpasswd.max_users` mapped to `Unlimited`, and combined with the default `$authenticated` publish policy on `**`, an anonymous client could self-register, obtain a token, and publish or overwrite packages. Make registration opt-in: - A missing `max_users` now disables registration (the enum default and the YAML mapping are `Disabled`); there is no YAML spelling for "unlimited". Operators set an explicit positive cap to allow sign-ups. - The in-memory user store (no `auth.htpasswd.file`) honored no cap and ignored config; it now takes the cap from `AuthState::load`. - `router()`/`try_router()` honor `auth.htpasswd.max_users` via `AuthState::in_memory_with_max_users` instead of hardcoding `Unlimited`, so library embedders inherit the opt-in default. - The bundled fallback `config.yaml` ships locked down (`max_users: -1`); a `--max-users` CLI override lets tests/benchmarks opt in. pnpm's e2e registry passes `--max-users 1000`. Test fixtures opt in explicitly where they create accounts. Addresses GHSA-fg2v-jp32-w784 (CAND-PNPM-029).
This commit is contained in:
@@ -47,6 +47,9 @@ impl TestRegistryInstance {
|
||||
// the npm uplink — matching how registry-mock served pacquet's tests.
|
||||
let mut config = Config::proxy(listen, storage.to_path_buf());
|
||||
config.public_url = url.trim_end_matches('/').to_string();
|
||||
// Registration is opt-in; tests that forward credentials create
|
||||
// accounts via adduser against this registry.
|
||||
config.auth.htpasswd.max_users = pnpr::MaxUsers::Unlimited;
|
||||
// A long TTL keeps the fixture packuments (whose `time` values are static)
|
||||
// from being treated as stale and refetched from the uplink.
|
||||
config.packument_ttl = std::time::Duration::from_hours(8760);
|
||||
|
||||
@@ -52,9 +52,11 @@ secret: pnpm-registry-mock-secret-key-32
|
||||
auth:
|
||||
htpasswd:
|
||||
file: ./htpasswd
|
||||
# Maximum amount of users allowed to register, defaults to "+inf".
|
||||
# You can set this to -1 to disable registration.
|
||||
#max_users: 1000
|
||||
# Maximum number of users allowed to self-register. Registration is
|
||||
# opt-in: it stays disabled unless this is set to a positive number
|
||||
# (set it to -1 to keep it disabled explicitly). This mock registry
|
||||
# opts in so the test suite can create accounts.
|
||||
max_users: 1000
|
||||
|
||||
plugins: ../node_modules
|
||||
|
||||
|
||||
@@ -131,13 +131,18 @@ impl std::fmt::Debug for AuthState {
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
/// All-in-memory auth state. Used when no record backend is
|
||||
/// configured and neither `auth.htpasswd.file` nor
|
||||
/// `auth.tokens.file` are set, and by tests that don't care about
|
||||
/// persistence.
|
||||
/// All-in-memory auth state for surfaces that never expose
|
||||
/// registration: a resolver-only deployment (registry disabled, so
|
||||
/// the adduser route is not mounted) and tests that don't care
|
||||
/// about persistence. Registration is left uncapped here because no
|
||||
/// untrusted caller can reach it; the production registration path
|
||||
/// goes through [`Self::load`], which honors `auth.htpasswd.max_users`.
|
||||
#[must_use]
|
||||
pub fn in_memory() -> Self {
|
||||
Self { users: Arc::new(UserStore::in_memory()), tokens: Arc::new(TokenStore::in_memory()) }
|
||||
Self {
|
||||
users: Arc::new(UserStore::in_memory(MaxUsers::Unlimited)),
|
||||
tokens: Arc::new(TokenStore::in_memory()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the auth state from the resolved config. A configured SQL
|
||||
@@ -198,7 +203,7 @@ impl AuthState {
|
||||
}
|
||||
let users: Arc<dyn UserBackend> = match auth.htpasswd.file.clone() {
|
||||
Some(path) => Arc::new(UserStore::open(path, auth.htpasswd.max_users)?),
|
||||
None => Arc::new(UserStore::in_memory()),
|
||||
None => Arc::new(UserStore::in_memory(auth.htpasswd.max_users)),
|
||||
};
|
||||
let tokens: Arc<dyn TokenBackend> = match auth.tokens.file.clone() {
|
||||
Some(path) => Arc::new(TokenStore::open(path)?),
|
||||
@@ -312,13 +317,15 @@ impl UserStore {
|
||||
/// In-memory store with no on-disk persistence. Used when
|
||||
/// `auth.htpasswd.file` is unset and by the existing
|
||||
/// `@pnpm/registry-mock` integration where every restart is a
|
||||
/// fresh process.
|
||||
/// fresh process. The registration cap is honored here just as it
|
||||
/// is for the file-backed store, so an unset `auth.htpasswd.file`
|
||||
/// does not silently re-open sign-ups that `max_users` denied.
|
||||
#[must_use]
|
||||
pub fn in_memory() -> Self {
|
||||
pub fn in_memory(max_users: MaxUsers) -> Self {
|
||||
Self {
|
||||
users: Mutex::new(HashMap::new()),
|
||||
path: None,
|
||||
max_users: MaxUsers::Unlimited,
|
||||
max_users,
|
||||
bcrypt_cost: DEFAULT_BCRYPT_COST,
|
||||
}
|
||||
}
|
||||
@@ -617,7 +624,7 @@ impl Default for TokenStore {
|
||||
|
||||
impl Default for UserStore {
|
||||
fn default() -> Self {
|
||||
Self::in_memory()
|
||||
Self::in_memory(MaxUsers::Unlimited)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,15 @@ async fn max_users_minus_one_disables_registration() {
|
||||
assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_honors_the_registration_cap() {
|
||||
// An unset `auth.htpasswd.file` must not re-open sign-ups that the
|
||||
// configured cap denied: the in-memory store enforces it too.
|
||||
let store = UserStore::in_memory(MaxUsers::Disabled);
|
||||
let err = store.add_or_login("alice", "secret").await.unwrap_err();
|
||||
assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_users_caps_new_registrations() {
|
||||
let store = UserStore {
|
||||
|
||||
@@ -297,25 +297,30 @@ pub struct TokensConfig {
|
||||
|
||||
/// Three-state cap on `auth.htpasswd.max_users`:
|
||||
///
|
||||
/// * absent → unlimited (verdaccio's `+infinity` default; the YAML
|
||||
/// `+inf` token is a float literal and won't parse into the
|
||||
/// `i64` field, so the only way to ask for "no cap" is to omit
|
||||
/// the key)
|
||||
/// * absent → registration disabled. Self-registration is opt-in:
|
||||
/// leaving the key out denies new sign-ups. Verdaccio defaults this
|
||||
/// to `+infinity`, but an open default lets any anonymous client
|
||||
/// create an account and then publish under an `$authenticated`
|
||||
/// policy, so pnpr refuses registration until an operator sets an
|
||||
/// explicit positive cap.
|
||||
/// * `-1` → registration disabled
|
||||
/// * non-negative `n` → at most `n` users
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MaxUsers {
|
||||
#[default]
|
||||
Unlimited,
|
||||
Disabled,
|
||||
Unlimited,
|
||||
Limited(u64),
|
||||
}
|
||||
|
||||
impl MaxUsers {
|
||||
/// Translate the YAML value into [`MaxUsers`]. Verdaccio accepts
|
||||
/// any signed integer here; negative anything other than `-1` is
|
||||
/// nonsense and is treated as "disabled" to err on the side of
|
||||
/// rejecting unsafe configs.
|
||||
/// Translate an explicit YAML value into [`MaxUsers`]. Verdaccio
|
||||
/// accepts any signed integer here; negative anything other than
|
||||
/// `-1` is nonsense and is treated as "disabled" to err on the
|
||||
/// side of rejecting unsafe configs. An omitted key never reaches
|
||||
/// this function — it maps to [`MaxUsers::Disabled`] in
|
||||
/// [`build_auth_config`], so there is no YAML spelling for
|
||||
/// "unlimited".
|
||||
fn from_yaml(value: i64) -> Self {
|
||||
if value < 0 { MaxUsers::Disabled } else { MaxUsers::Limited(value as u64) }
|
||||
}
|
||||
@@ -1301,7 +1306,7 @@ fn build_auth_config(file: &AuthFile, base_dir: &Path) -> AuthConfig {
|
||||
AuthConfig {
|
||||
htpasswd: HtpasswdConfig {
|
||||
file: htpasswd_file,
|
||||
max_users: file.htpasswd.max_users.map_or(MaxUsers::Unlimited, MaxUsers::from_yaml),
|
||||
max_users: file.htpasswd.max_users.map_or(MaxUsers::Disabled, MaxUsers::from_yaml),
|
||||
},
|
||||
tokens: TokensConfig { file: tokens_file },
|
||||
}
|
||||
|
||||
@@ -936,7 +936,22 @@ fn auth_block_absent_keeps_in_memory_defaults() {
|
||||
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
|
||||
assert!(config.auth.htpasswd.file.is_none());
|
||||
assert!(config.auth.tokens.file.is_none());
|
||||
assert_eq!(config.auth.htpasswd.max_users, super::MaxUsers::Unlimited);
|
||||
// Registration is opt-in: an omitted cap denies new sign-ups.
|
||||
assert_eq!(config.auth.htpasswd.max_users, super::MaxUsers::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_max_users_absent_disables_registration() {
|
||||
let yaml = "\
|
||||
storage: ./s
|
||||
auth:
|
||||
htpasswd:
|
||||
file: ./htpasswd
|
||||
uplinks: {}
|
||||
packages: {}
|
||||
";
|
||||
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
|
||||
assert_eq!(config.auth.htpasswd.max_users, super::MaxUsers::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -123,10 +123,13 @@ pub enum RegistryError {
|
||||
advisories: String,
|
||||
},
|
||||
|
||||
/// `auth.htpasswd.max_users: -1` blocks new registrations.
|
||||
/// Returned for adduser on a username that doesn't already
|
||||
/// exist; existing-user logins are unaffected.
|
||||
#[display("New user registration is disabled by auth.htpasswd.max_users: -1")]
|
||||
/// New-user registration is off: `auth.htpasswd.max_users` is
|
||||
/// unset (the secure default) or set to `-1`. Returned for adduser
|
||||
/// on a username that doesn't already exist; existing-user logins
|
||||
/// are unaffected.
|
||||
#[display(
|
||||
"New user registration is disabled. Set auth.htpasswd.max_users to a positive number to allow sign-ups"
|
||||
)]
|
||||
RegistrationDisabled,
|
||||
|
||||
/// `auth.htpasswd.max_users: N` cap reached. Returned for
|
||||
|
||||
Reference in New Issue
Block a user