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:
Zoltan Kochan
2026-06-22 16:22:42 +02:00
committed by GitHub
parent 4c4acb654f
commit cdc897f838
7 changed files with 72 additions and 28 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 },
}

View 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]

View File

@@ -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