Skip to content

fix(pnpr): make user self-registration opt-in by default#12581

Merged
zkochan merged 1 commit into
mainfrom
vuln-029
Jun 22, 2026
Merged

fix(pnpr): make user self-registration opt-in by default#12581
zkochan merged 1 commit into
mainfrom
vuln-029

Conversation

@zkochan

@zkochan zkochan commented Jun 22, 2026

Copy link
Copy Markdown
Member

Summary

pnpr allowed anonymous user self-registration in its default configuration, which — combined with the default $authenticated publish policy on ** — let any anonymous client create an account, obtain a token, and publish or overwrite arbitrary packages.

This makes registration opt-in:

  • An omitted auth.htpasswd.max_users now means registration disabled instead of unlimited. Operators that want self-registration must set an explicit positive cap; there is no longer a YAML spelling for "unlimited".
  • The in-memory user store (used when auth.htpasswd.file is unset) previously hardcoded Unlimited and ignored the configured cap entirely — so even max_users: -1 was silently overridden. The cap is now threaded into the in-memory store too.
  • The public embedder helpers router() / try_router() built in-memory auth that ignored auth.htpasswd.max_users. They now honor the configured cap (via AuthState::in_memory_with_max_users), so embedders get the same opt-in default as the production serve() path.
  • The bundled config.yaml is compiled into the binary as the production fallback (ConfigSource::Bundled), so it ships locked down (max_users: -1). A new --max-users CLI override lets tests/benchmarks enable registration on top of it without a custom YAML; pnpm's e2e registry setup passes --max-users 1000 to opt in.

In-repo test fixtures (pnpr integration tests, pacquet's TestRegistry) opt in explicitly where they create accounts.

Addresses GHSA-fg2v-jp32-w784 (CAND-PNPM-029).

Squash Commit Body

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).

Checklist

  • The change is implemented in both the TypeScript CLI and the Rust pacquet/ port, or the description notes what still needs porting.
  • Added or updated tests.

Written by an agent (Claude Code, claude-opus-4-8).

An omitted `auth.htpasswd.max_users` previously mapped to
`MaxUsers::Unlimited`, leaving public account creation open in the
default configuration. Combined with the default `$authenticated`
publish policy on `**`, an anonymous client could self-register, obtain
a token, and publish or overwrite arbitrary packages.

Treat a missing limit as registration disabled (the enum default is now
`Disabled` and the YAML mapping returns `Disabled` when the key is
absent); operators that want self-registration must set an explicit
positive cap. There is no longer a YAML spelling for "unlimited".

Also close a second bypass: the in-memory user store (used when
`auth.htpasswd.file` is unset) hardcoded `Unlimited` and ignored the
configured cap entirely, so even `max_users: -1` was silently
overridden. `UserStore::in_memory` now takes the cap and
`AuthState::load` threads `auth.htpasswd.max_users` into it.

The registry-mock config and pacquet's TestRegistry opt in explicitly so
the test suites can still create accounts.

Addresses GHSA-fg2v-jp32-w784 (CAND-PNPM-029).
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The default value of MaxUsers is changed from Unlimited to Disabled, so omitting auth.htpasswd.max_users in config now disables user self-registration. UserStore::in_memory gains a MaxUsers parameter; AuthState::load and AuthState::in_memory are updated accordingly. The error message, config.yaml, config tests, auth tests, and the test registry are all updated to reflect the new default.

Changes

Registration disabled by default

Layer / File(s) Summary
MaxUsers default and build_auth_config fallback
pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/src/error.rs
MaxUsers enum default variant changed from Unlimited to Disabled; build_auth_config fallback for absent max_users key updated to MaxUsers::Disabled; RegistrationDisabled display message broadened to cover the unset-or--1 case.
UserStore::in_memory signature and AuthState wiring
pnpr/crates/pnpr/src/auth.rs
UserStore::in_memory updated to accept and store a MaxUsers cap; AuthState::in_memory passes MaxUsers::Unlimited; AuthState::load passes auth.htpasswd.max_users when no file is configured; UserStore::default updated to call the new signature.
Tests, config.yaml, and test registry
pnpr/crates/pnpr/src/config/tests.rs, pnpr/crates/pnpr/src/auth/tests.rs, pnpr/crates/pnpr/config.yaml, pacquet/crates/testing-utils/src/registry.rs
Config tests updated/added to assert MaxUsers::Disabled as the absent-key default; new auth test verifies in-memory UserStore rejects registration when disabled; config.yaml sets max_users: 1000 with updated docs; test registry explicitly sets MaxUsers::Unlimited.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

product: pnpr

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vuln-029

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Make pnpr user self-registration opt-in by default
🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 📝 Documentation 🕐 20-40 Minutes

Grey Divider

Description

• Default missing "auth.htpasswd.max_users" to registration disabled (secure-by-default).
• Enforce the registration cap for in-memory user storage when no htpasswd file is configured.
• Update registry-mock/TestRegistry configs and add tests to preserve expected self-registration
 flows.
Diagram

graph TD
  A["Operator YAML config"] --> B["build_auth_config"] --> C["AuthConfig max_users (default Disabled)"] --> D["AuthState::load"] --> E["UserStore (file/in-memory) w/ cap"] --> F["adduser: add_or_login"] --> G["Registration allowed/denied"]
  T["TestRegistry / registry-mock"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Explicit boolean flag (e.g., auth.allow_registration)
  • ➕ Clear intent and avoids overloading max_users semantics
  • ➕ Can keep max_users purely as a cap when registration is enabled
  • ➖ More config surface area and migration complexity
  • ➖ Still needs careful defaults and interaction with existing configs
2. Keep current semantics but require explicit 'unlimited' token
  • ➕ Preserves backward compatibility for operators relying on implicit unlimited
  • ➕ Allows a deliberate opt-in to unlimited registrations
  • ➖ Reintroduces a footgun if documentation/config examples are copied blindly
  • ➖ Requires adding a custom YAML parsing token (since +inf doesn't parse as i64)
3. Disable adduser route by default unless explicitly enabled
  • ➕ Strong separation between auth for token verification vs registration surface
  • ➕ Reduces risk of accidental exposure even if caps are misconfigured
  • ➖ Bigger behavioral/API surface change; could break clients expecting adduser availability
  • ➖ Still needs cap enforcement once enabled

Recommendation: The PR’s approach (missing max_users => Disabled, and enforcing the cap in both file-backed and in-memory stores) is the best security tradeoff: it makes the unsafe default impossible while keeping the existing max_users mechanism as the single opt-in knob. The key additional win is closing the in-memory-store bypass so configuration intent is consistently enforced.

Files changed (7) +72 / -28

Bug fix (3) +39 / -24
auth.rsThread max_users into in-memory user store construction +17/-10

Thread max_users into in-memory user store construction

• Updates AuthState to pass the configured max_users into the in-memory UserStore when no htpasswd file is configured. Changes UserStore::in_memory to store the provided cap instead of hardcoding Unlimited, ensuring consistent enforcement.

pnpr/crates/pnpr/src/auth.rs

config.rsDefault missing max_users to Disabled and clarify semantics +15/-10

Default missing max_users to Disabled and clarify semantics

• Changes MaxUsers default to Disabled and updates the YAML-to-config mapping so an absent auth.htpasswd.max_users disables registration. Expands documentation to explain why pnpr deviates from Verdaccio’s implicit unlimited default and removes any YAML spelling for “unlimited”.

pnpr/crates/pnpr/src/config.rs

error.rsImprove error message for disabled registration default +7/-4

Improve error message for disabled registration default

• Updates RegistrationDisabled documentation and display string to reflect both cases: unset max_users (secure default) and explicit -1. Provides a clearer operator action: set a positive max_users to enable sign-ups.

pnpr/crates/pnpr/src/error.rs

Tests (3) +28 / -1
registry.rsOpt in to self-registration for TestRegistry fixtures +3/-0

Opt in to self-registration for TestRegistry fixtures

• Sets TestRegistry’s config to allow self-registration by explicitly setting max_users to Unlimited. Adds comments explaining why tests need registration enabled.

pacquet/crates/testing-utils/src/registry.rs

tests.rsAdd regression test for in-memory store registration cap +9/-0

Add regression test for in-memory store registration cap

• Adds a test asserting that the in-memory user store returns Forbidden when max_users disables registration. Documents the security invariant that missing htpasswd.file must not reopen sign-ups.

pnpr/crates/pnpr/src/auth/tests.rs

tests.rsUpdate config parsing expectations for opt-in registration +16/-1

Update config parsing expectations for opt-in registration

• Updates existing tests to expect Disabled when the auth block or max_users key is absent. Adds a new test to ensure an explicit htpasswd.file with missing max_users also disables registration.

pnpr/crates/pnpr/src/config/tests.rs

Other (1) +5 / -3
config.yamlMake mock registry explicitly enable registration +5/-3

Make mock registry explicitly enable registration

• Updates the bundled registry-mock configuration comments to reflect opt-in registration semantics. Sets max_users explicitly so integration tests can still create accounts.

pnpr/crates/pnpr/config.yaml

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Bundled config re-enables signup 🐞 Bug ⛨ Security ⭐ New
Description
pnpr falls back to the bundled DEFAULT_CONFIG_YAML when no config file exists, and this PR sets
auth.htpasswd.max_users: 1000 in that bundled YAML, which re-enables anonymous self-registration
for the default binary config. Because the same bundled config also sets `packages['**'].publish:
$authenticated and the registry mounts the adduser` endpoint, any unauthenticated client who can
reach the server can create an account, obtain a token, and publish/overwrite packages (until the
1000-user cap is hit).
Code

pnpr/crates/pnpr/config.yaml[R55-59]

+    # 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
Evidence
The bundled YAML is explicitly compiled into DEFAULT_CONFIG_YAML and used as the fallback config
source, and the registry router mounts the adduser route when the registry feature is enabled. The
bundled YAML sets both max_users: 1000 and publish: $authenticated on **, enabling the
anonymous-register-to-publish escalation if the server is reachable by untrusted clients.

pnpr/crates/pnpr/src/config.rs[19-40]
pnpr/crates/pnpr/src/main.rs[8-13]
pnpr/crates/pnpr/config.yaml[52-60]
pnpr/crates/pnpr/config.yaml[75-116]
pnpr/crates/pnpr/src/server.rs[280-305]
pnpr/crates/pnpr/src/server.rs[593-606]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The bundled default config (`DEFAULT_CONFIG_YAML`) is used when no config file is found, but it currently opts into self-registration via `auth.htpasswd.max_users: 1000`. This undermines the goal of making self-registration opt-in by default, because exposing a default `pnpr` instance to untrusted clients still enables the `adduser` -> token issuance -> publish escalation path.

## Issue Context
- `DEFAULT_CONFIG_YAML` is compiled into the binary and used as a fallback when no `config.yaml` exists.
- The bundled YAML also includes `packages['**'].publish: $authenticated`, and the server mounts `PUT /-/user/org.couchdb.user:{name}` when the registry surface is enabled.

## Fix Focus Areas
- pnpr/crates/pnpr/config.yaml[52-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. router() ignores max_users 🐞 Bug ⛨ Security
Description
AuthState::in_memory() hardcodes MaxUsers::Unlimited, but server::router()/try_router() build
routers with AuthState::in_memory() (in-memory auth) even when the registry surface is enabled,
bypassing the new opt-in default registration cap. Embedders using these helpers can unintentionally
re-open anonymous self-registration and token issuance (PUT /-/user/org.couchdb.user:{name}), which
combined with the default $authenticated publish policy enables anonymous-to-publisher escalation.
Code

pnpr/crates/pnpr/src/auth.rs[R129-140]

+    /// 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()),
+        }
Evidence
The repo now defines the secure default as MaxUsers::Disabled (and YAML-absent maps to Disabled),
but AuthState::in_memory() still constructs an uncapped UserStore (Unlimited).
server::router()/try_router() use AuthState::in_memory() for embedders; when registry routes are
mounted, PUT /-/user/org.couchdb.user:{name} dispatches to add_user(), which calls
users.add_or_login() and then issues a bearer token. The bundled config’s default publish policy for
'**' is $authenticated, so any self-registered user can publish/overwrite packages.

pnpr/crates/pnpr/src/auth.rs[128-141]
pnpr/crates/pnpr/src/server.rs[148-168]
pnpr/crates/pnpr/src/server.rs[280-304]
pnpr/crates/pnpr/src/server.rs[592-606]
pnpr/crates/pnpr/src/server.rs[991-1030]
pnpr/crates/pnpr/src/config.rs[272-314]
pnpr/crates/pnpr/src/config.rs[945-983]
pnpr/crates/pnpr/config.yaml[75-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MaxUsers` now defaults to `Disabled` (registration opt-in), but `server::router()` / `server::try_router()` still use `AuthState::in_memory()`, and `AuthState::in_memory()` hardcodes `MaxUsers::Unlimited`. This re-enables self-registration for any embedder using these helpers, contradicting the new secure default.
## Issue Context
- Production `serve()` goes through `AuthState::load()` and is safe.
- The public `router()` helper is explicitly for embedders and can mount the registry surface (and thus adduser).
## Fix Focus Areas
- pnpr/crates/pnpr/src/auth.rs[128-141]
- pnpr/crates/pnpr/src/server.rs[148-168]
- pnpr/crates/pnpr/src/config.rs[272-314]
### Suggested fix approach
- Add an in-memory constructor that takes a cap (e.g. `AuthState::in_memory_with_max_users(max_users: MaxUsers)`), or change `AuthState::in_memory()` to use `MaxUsers::Disabled` by default.
- Update `server::router(config)` and `server::try_router(config)` to construct in-memory auth that *honors* `config.auth.htpasswd.max_users` (so the new secure default applies to embedders too).
- Update the doc comment on `AuthState::in_memory()` to avoid asserting registration is unreachable unless that is guaranteed by construction.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit efb4952

Results up to commit efb4952


🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)


Action required
1. router() ignores max_users 🐞 Bug ⛨ Security
Description
AuthState::in_memory() hardcodes MaxUsers::Unlimited, but server::router()/try_router() build
routers with AuthState::in_memory() (in-memory auth) even when the registry surface is enabled,
bypassing the new opt-in default registration cap. Embedders using these helpers can unintentionally
re-open anonymous self-registration and token issuance (PUT /-/user/org.couchdb.user:{name}), which
combined with the default $authenticated publish policy enables anonymous-to-publisher escalation.
Code

pnpr/crates/pnpr/src/auth.rs[R129-140]

+    /// 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()),
+        }
Evidence
The repo now defines the secure default as MaxUsers::Disabled (and YAML-absent maps to Disabled),
but AuthState::in_memory() still constructs an uncapped UserStore (Unlimited).
server::router()/try_router() use AuthState::in_memory() for embedders; when registry routes are
mounted, PUT /-/user/org.couchdb.user:{name} dispatches to add_user(), which calls
users.add_or_login() and then issues a bearer token. The bundled config’s default publish policy for
'**' is $authenticated, so any self-registered user can publish/overwrite packages.

pnpr/crates/pnpr/src/auth.rs[128-141]
pnpr/crates/pnpr/src/server.rs[148-168]
pnpr/crates/pnpr/src/server.rs[280-304]
pnpr/crates/pnpr/src/server.rs[592-606]
pnpr/crates/pnpr/src/server.rs[991-1030]
pnpr/crates/pnpr/src/config.rs[272-314]
pnpr/crates/pnpr/src/config.rs[945-983]
pnpr/crates/pnpr/config.yaml[75-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MaxUsers` now defaults to `Disabled` (registration opt-in), but `server::router()` / `server::try_router()` still use `AuthState::in_memory()`, and `AuthState::in_memory()` hardcodes `MaxUsers::Unlimited`. This re-enables self-registration for any embedder using these helpers, contradicting the new secure default.

## Issue Context
- Production `serve()` goes through `AuthState::load()` and is safe.
- The public `router()` helper is explicitly for embedders and can mount the registry surface (and thus adduser).

## Fix Focus Areas
- pnpr/crates/pnpr/src/auth.rs[128-141]
- pnpr/crates/pnpr/src/server.rs[148-168]
- pnpr/crates/pnpr/src/config.rs[272-314]

### Suggested fix approach
- Add an in-memory constructor that takes a cap (e.g. `AuthState::in_memory_with_max_users(max_users: MaxUsers)`), or change `AuthState::in_memory()` to use `MaxUsers::Disabled` by default.
- Update `server::router(config)` and `server::try_router(config)` to construct in-memory auth that *honors* `config.auth.htpasswd.max_users` (so the new secure default applies to embedders too).
- Update the doc comment on `AuthState::in_memory()` to avoid asserting registration is unreachable unless that is guaranteed by construction.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment on lines +129 to +140
/// 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()),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Router() ignores max_users 🐞 Bug ⛨ Security

AuthState::in_memory() hardcodes MaxUsers::Unlimited, but server::router()/try_router() build
routers with AuthState::in_memory() (in-memory auth) even when the registry surface is enabled,
bypassing the new opt-in default registration cap. Embedders using these helpers can unintentionally
re-open anonymous self-registration and token issuance (PUT /-/user/org.couchdb.user:{name}), which
combined with the default $authenticated publish policy enables anonymous-to-publisher escalation.
Agent Prompt
## Issue description
`MaxUsers` now defaults to `Disabled` (registration opt-in), but `server::router()` / `server::try_router()` still use `AuthState::in_memory()`, and `AuthState::in_memory()` hardcodes `MaxUsers::Unlimited`. This re-enables self-registration for any embedder using these helpers, contradicting the new secure default.

## Issue Context
- Production `serve()` goes through `AuthState::load()` and is safe.
- The public `router()` helper is explicitly for embedders and can mount the registry surface (and thus adduser).

## Fix Focus Areas
- pnpr/crates/pnpr/src/auth.rs[128-141]
- pnpr/crates/pnpr/src/server.rs[148-168]
- pnpr/crates/pnpr/src/config.rs[272-314]

### Suggested fix approach
- Add an in-memory constructor that takes a cap (e.g. `AuthState::in_memory_with_max_users(max_users: MaxUsers)`), or change `AuthState::in_memory()` to use `MaxUsers::Disabled` by default.
- Update `server::router(config)` and `server::try_router(config)` to construct in-memory auth that *honors* `config.auth.htpasswd.max_users` (so the new secure default applies to embedders too).
- Update the doc comment on `AuthState::in_memory()` to avoid asserting registration is unreachable unless that is guaranteed by construction.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 52cdea2. router() / try_router() now build their in-memory auth via the new AuthState::in_memory_with_max_users(config.auth.htpasswd.max_users), so embedders that mount the registry surface inherit the configured registration cap (and the new opt-in default) instead of an unconditional Unlimited. AuthState::in_memory() stays uncapped only for the resolver-only path (registry disabled, so the adduser route is not mounted) and for tests that pre-seed users. A regression test (router_honors_disabled_registration_cap) asserts adduser returns 403 through router() when the cap is disabled.


Written by an agent (Claude Code, claude-opus-4-8).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pnpr/crates/pnpr/config.yaml`:
- Around line 55-59: The max_users field in the bundled config.yaml is set to
1000, which enables self-registration by default in production since this config
is compiled directly into the binary via include_str! in config.rs and serves as
the DEFAULT_CONFIG_YAML fallback. Change max_users from 1000 to -1 (or 0) in
config.yaml to disable self-registration by default, ensuring deployments are
secure by default. Test suites that require account creation can override this
setting through explicit config files or CLI flags rather than relying on the
production default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c0a90561-a67b-44e9-b022-db0662719ae1

📥 Commits

Reviewing files that changed from the base of the PR and between 0ec878d and efb4952.

📒 Files selected for processing (7)
  • pacquet/crates/testing-utils/src/registry.rs
  • pnpr/crates/pnpr/config.yaml
  • pnpr/crates/pnpr/src/auth.rs
  • pnpr/crates/pnpr/src/auth/tests.rs
  • pnpr/crates/pnpr/src/config.rs
  • pnpr/crates/pnpr/src/config/tests.rs
  • pnpr/crates/pnpr/src/error.rs

Comment thread pnpr/crates/pnpr/config.yaml
Comment on lines +55 to +59
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Bundled config re-enables signup 🐞 Bug ⛨ Security

pnpr falls back to the bundled DEFAULT_CONFIG_YAML when no config file exists, and this PR sets
auth.htpasswd.max_users: 1000 in that bundled YAML, which re-enables anonymous self-registration
for the default binary config. Because the same bundled config also sets `packages['**'].publish:
$authenticated and the registry mounts the adduser` endpoint, any unauthenticated client who can
reach the server can create an account, obtain a token, and publish/overwrite packages (until the
1000-user cap is hit).
Agent Prompt
## Issue description
The bundled default config (`DEFAULT_CONFIG_YAML`) is used when no config file is found, but it currently opts into self-registration via `auth.htpasswd.max_users: 1000`. This undermines the goal of making self-registration opt-in by default, because exposing a default `pnpr` instance to untrusted clients still enables the `adduser` -> token issuance -> publish escalation path.

## Issue Context
- `DEFAULT_CONFIG_YAML` is compiled into the binary and used as a fallback when no `config.yaml` exists.
- The bundled YAML also includes `packages['**'].publish: $authenticated`, and the server mounts `PUT /-/user/org.couchdb.user:{name}` when the registry surface is enabled.

## Fix Focus Areas
- pnpr/crates/pnpr/config.yaml[52-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 52cdea2. The bundled config.yaml now ships locked down (max_users: -1), so the compiled-in fallback used when no config is provided denies self-registration. Because pnpm's e2e registry setup launches pnpr with this bundled config and needs to create a mock account, a new --max-users CLI override was added; the e2e setup passes --max-users 1000 to opt in, and operators/tests that want registration set a positive cap in their own config or via the flag. bundled_default_config_enforces_its_protections now also asserts the bundled default is MaxUsers::Disabled.


Written by an agent (Claude Code, claude-opus-4-8).

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit efb4952

@github-actions

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00      7.4±0.16ms   588.9 KB/sec    1.01      7.5±0.30ms   581.0 KB/sec

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.03%. Comparing base (1c04a00) to head (efb4952).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/auth.rs 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12581      +/-   ##
==========================================
+ Coverage   87.99%   88.03%   +0.04%     
==========================================
  Files         324      326       +2     
  Lines       46008    46329     +321     
==========================================
+ Hits        40483    40788     +305     
- Misses       5525     5541      +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.525 ± 0.161 4.330 4.815 1.58 ± 0.08
pacquet@main 4.419 ± 0.103 4.325 4.652 1.54 ± 0.06
pnpr@HEAD 2.872 ± 0.092 2.782 3.038 1.00
pnpr@main 2.906 ± 0.169 2.714 3.175 1.01 ± 0.07
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.524518647140001,
      "stddev": 0.1607548415586111,
      "median": 4.45382229794,
      "user": 3.8057292600000006,
      "system": 3.36535364,
      "min": 4.33042300594,
      "max": 4.81450649094,
      "times": [
        4.59419681694,
        4.71696112794,
        4.4699664519399995,
        4.4297648689399995,
        4.81450649094,
        4.40319833094,
        4.6559832099400005,
        4.39250802394,
        4.33042300594,
        4.43767814394
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.41897514344,
      "stddev": 0.10263132624513106,
      "median": 4.3854818604400005,
      "user": 3.8053670600000005,
      "system": 3.32773694,
      "min": 4.32490134594,
      "max": 4.65231354694,
      "times": [
        4.49836622794,
        4.38203747094,
        4.33246141594,
        4.49021542794,
        4.42595041794,
        4.35206811294,
        4.34251121794,
        4.38892624994,
        4.65231354694,
        4.32490134594
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.87157348564,
      "stddev": 0.09223409763153248,
      "median": 2.8395631419400003,
      "user": 2.8019207600000002,
      "system": 2.9478556399999993,
      "min": 2.78170633294,
      "max": 3.03832918994,
      "times": [
        3.02489958994,
        2.84860619294,
        2.79542157694,
        2.81549497694,
        2.86170404794,
        2.78170633294,
        3.03832918994,
        2.83052009094,
        2.9125699749400003,
        2.80648288294
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.90576626394,
      "stddev": 0.16859638190283524,
      "median": 2.8208592584399996,
      "user": 2.7781003599999994,
      "system": 2.92534574,
      "min": 2.71433024894,
      "max": 3.17540224594,
      "times": [
        2.9995841579399998,
        2.80071908994,
        2.76045924194,
        3.07741397294,
        2.79804157994,
        2.77635415094,
        2.84099942694,
        3.17540224594,
        2.71433024894,
        3.11435852394
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 637.0 ± 6.6 628.0 647.4 1.00
pacquet@main 654.2 ± 66.2 618.7 837.9 1.03 ± 0.10
pnpr@HEAD 720.0 ± 16.9 697.5 749.6 1.13 ± 0.03
pnpr@main 709.2 ± 74.2 658.7 914.1 1.11 ± 0.12
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6369778333200001,
      "stddev": 0.006611774828161689,
      "median": 0.63599199372,
      "user": 0.39125074,
      "system": 1.3134839,
      "min": 0.62798550622,
      "max": 0.64743640422,
      "times": [
        0.6366513112200001,
        0.62798550622,
        0.6461042572200001,
        0.6373393352200001,
        0.63520349222,
        0.6331367412200001,
        0.6284609242200001,
        0.63533267622,
        0.64743640422,
        0.6421276852200001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6541974085200001,
      "stddev": 0.06616206609087924,
      "median": 0.63297331622,
      "user": 0.39492634,
      "system": 1.3018328999999997,
      "min": 0.6187057222200001,
      "max": 0.8379480502200001,
      "times": [
        0.6314452932200001,
        0.63242979322,
        0.62032608422,
        0.6187057222200001,
        0.62480476722,
        0.6343976962200001,
        0.63351683922,
        0.63761051922,
        0.8379480502200001,
        0.67078932022
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.72002476822,
      "stddev": 0.01692165509477868,
      "median": 0.71938317022,
      "user": 0.41752964,
      "system": 1.375119,
      "min": 0.69753440422,
      "max": 0.74961496622,
      "times": [
        0.72989104922,
        0.71684667122,
        0.70760126022,
        0.7219196692200001,
        0.72352441322,
        0.69959504622,
        0.7411296982200001,
        0.71259050422,
        0.74961496622,
        0.69753440422
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.70918959292,
      "stddev": 0.0741501201261686,
      "median": 0.69267425672,
      "user": 0.39973334,
      "system": 1.3496556,
      "min": 0.6586549582200001,
      "max": 0.9140559922200001,
      "times": [
        0.66978187822,
        0.6586549582200001,
        0.7031169782200001,
        0.71069490022,
        0.69866362822,
        0.6672042852200001,
        0.70500165922,
        0.67803676422,
        0.9140559922200001,
        0.68668488522
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.704 ± 0.009 4.687 4.725 1.68 ± 0.10
pacquet@main 4.714 ± 0.031 4.655 4.751 1.68 ± 0.10
pnpr@HEAD 2.807 ± 0.163 2.630 3.101 1.00
pnpr@main 2.808 ± 0.186 2.668 3.116 1.00 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.704171346979999,
      "stddev": 0.009392091669694161,
      "median": 4.7036110750799995,
      "user": 3.9028207000000004,
      "system": 3.3171384799999997,
      "min": 4.6870322485800004,
      "max": 4.72474612958,
      "times": [
        4.70323134458,
        4.70050042458,
        4.6870322485800004,
        4.70399080558,
        4.69932283958,
        4.70546344758,
        4.70987839758,
        4.72474612958,
        4.70525316458,
        4.70229466758
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.71362902078,
      "stddev": 0.030915367074377476,
      "median": 4.71323181258,
      "user": 3.8967301,
      "system": 3.2972211799999998,
      "min": 4.65504386058,
      "max": 4.75143167458,
      "times": [
        4.68614022858,
        4.70046014658,
        4.74644839858,
        4.65504386058,
        4.74579884158,
        4.70597744558,
        4.69507390158,
        4.72048617958,
        4.75143167458,
        4.72942953058
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.8072888275800003,
      "stddev": 0.16278306252925287,
      "median": 2.77421694258,
      "user": 2.6237187,
      "system": 2.8085885800000003,
      "min": 2.6299673825800003,
      "max": 3.10069374958,
      "times": [
        2.78866080158,
        2.69869779158,
        2.6781662395800003,
        3.07449229658,
        2.6299673825800003,
        2.68600304858,
        3.10069374958,
        2.76446633558,
        2.86777308058,
        2.7839675495800003
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.8082351182800003,
      "stddev": 0.1855582043592178,
      "median": 2.71452733508,
      "user": 2.6324373,
      "system": 2.81152778,
      "min": 2.66772963758,
      "max": 3.11612211658,
      "times": [
        2.67642330458,
        2.7174466975800002,
        2.73262923458,
        3.06961413958,
        2.67219624658,
        3.11612211658,
        2.68231418358,
        2.71160797258,
        3.03626764958,
        2.66772963758
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.372 ± 0.011 1.354 1.387 2.06 ± 0.10
pacquet@main 1.385 ± 0.037 1.348 1.468 2.08 ± 0.11
pnpr@HEAD 0.670 ± 0.020 0.651 0.724 1.01 ± 0.06
pnpr@main 0.667 ± 0.030 0.650 0.752 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3717230387400001,
      "stddev": 0.011345479768820742,
      "median": 1.37245172134,
      "user": 1.36388818,
      "system": 1.7309395200000002,
      "min": 1.35361644034,
      "max": 1.38748291934,
      "times": [
        1.35361644034,
        1.35808876134,
        1.38748291934,
        1.36984652334,
        1.38119374934,
        1.38035687334,
        1.38167108134,
        1.36128367834,
        1.37505691934,
        1.3686334413399999
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.38541006804,
      "stddev": 0.0373329541773004,
      "median": 1.36924312034,
      "user": 1.3747136799999997,
      "system": 1.7249316199999996,
      "min": 1.34791172234,
      "max": 1.46796917134,
      "times": [
        1.46796917134,
        1.39941168334,
        1.36366283934,
        1.42706109734,
        1.38935435334,
        1.36716115534,
        1.34791172234,
        1.35308241734,
        1.37008594734,
        1.36840029334
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6700527231400001,
      "stddev": 0.020381674708272172,
      "median": 0.6661867373400001,
      "user": 0.35024608,
      "system": 1.2786172199999999,
      "min": 0.6512052743400001,
      "max": 0.7242738893400001,
      "times": [
        0.6512052743400001,
        0.6581585663400001,
        0.7242738893400001,
        0.6688314713400001,
        0.6740835073400001,
        0.6608115863400001,
        0.6722677103400001,
        0.6635420033400001,
        0.6577462113400001,
        0.6696070113400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.66655956184,
      "stddev": 0.030388732703024494,
      "median": 0.65628651384,
      "user": 0.34746898,
      "system": 1.26658442,
      "min": 0.65023338634,
      "max": 0.7515785643400001,
      "times": [
        0.6544629593400001,
        0.65547440034,
        0.6541176713400001,
        0.6694259563400001,
        0.6570986273400001,
        0.6579245843400001,
        0.65023338634,
        0.6631658843400001,
        0.7515785643400001,
        0.6521135843400001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.037 ± 0.021 3.010 3.071 4.53 ± 0.08
pacquet@main 3.059 ± 0.033 3.014 3.120 4.57 ± 0.09
pnpr@HEAD 0.670 ± 0.011 0.654 0.682 1.00
pnpr@main 0.677 ± 0.060 0.646 0.848 1.01 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0374180699599997,
      "stddev": 0.021210499687235117,
      "median": 3.03436172286,
      "user": 1.8294230200000001,
      "system": 1.96067746,
      "min": 3.01026968436,
      "max": 3.0713667823599997,
      "times": [
        3.0555554473599997,
        3.03328043036,
        3.01026968436,
        3.03544301536,
        3.01302295736,
        3.0164939943599998,
        3.05012808436,
        3.06043385936,
        3.0713667823599997,
        3.0281864443599997
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.05913640826,
      "stddev": 0.03306117919728226,
      "median": 3.05027362386,
      "user": 1.82804322,
      "system": 1.9906953599999997,
      "min": 3.01384834336,
      "max": 3.12029473436,
      "times": [
        3.01384834336,
        3.06042369236,
        3.0384987143599997,
        3.03208249236,
        3.03502541236,
        3.07509275836,
        3.04012355536,
        3.12029473436,
        3.09147786136,
        3.08449651836
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6699100851600001,
      "stddev": 0.010661003251694693,
      "median": 0.67223229586,
      "user": 0.34897471999999996,
      "system": 1.2866737599999998,
      "min": 0.65367259736,
      "max": 0.6823357763600001,
      "times": [
        0.6618219053600001,
        0.65367259736,
        0.68097544336,
        0.67100618036,
        0.6541935963600001,
        0.6734584113600001,
        0.6823357763600001,
        0.6656317623600001,
        0.67986228536,
        0.6761428933600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6769165678600001,
      "stddev": 0.06043665737660217,
      "median": 0.6583220143600002,
      "user": 0.35869572,
      "system": 1.2669539599999997,
      "min": 0.64627811036,
      "max": 0.8478187343600001,
      "times": [
        0.6658355893600001,
        0.6549187633600001,
        0.6549857603600001,
        0.65370921536,
        0.6638320643600001,
        0.64627811036,
        0.6616582683600001,
        0.6520393483600001,
        0.8478187343600001,
        0.6680898243600001
      ]
    }
  ]
}

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12581
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,704.17 ms
(+9.67%)Baseline: 4,289.32 ms
5,147.18 ms
(91.39%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,037.42 ms
(+0.30%)Baseline: 3,028.28 ms
3,633.94 ms
(83.58%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,371.72 ms
(+2.77%)Baseline: 1,334.75 ms
1,601.70 ms
(85.64%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,524.52 ms
(+14.31%)Baseline: 3,958.05 ms
4,749.66 ms
(95.26%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
636.98 ms
(+3.13%)Baseline: 617.64 ms
741.17 ms
(85.94%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12581
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,807.29 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
669.91 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
670.05 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,871.57 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
720.02 ms
🐰 View full continuous benchmarking report in Bencher

@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 22, 2026
@zkochan zkochan merged commit cdc897f into main Jun 22, 2026
39 of 41 checks passed
@zkochan zkochan deleted the vuln-029 branch June 22, 2026 14:22
zkochan added a commit that referenced this pull request Jun 22, 2026
The proxied-tarball integrity fix (#12570, GHSA-5f9g-98vq-2jxw)
started reconstructing each rewritten dist.tarball filename from its
version key (`<name>-<version>.tgz`). That assumes upstream tarball names
are always canonical, which breaks packages like esprima-fb whose real
npm tarball is `esprima-fb-3001.0001.0000-dev-harmony-fb.tgz` for version
`3001.1.0-dev-harmony-fb`: the rewritten URL no longer matched what npm
hosts, so the client recorded the wrong lockfile URL and the proxied
fetch 404'd.

Preserve the upstream basename verbatim in the rewrite again, and resolve
a tarball request's version and dist.integrity by matching the requested
filename against each version's dist.tarball basename instead of parsing
the version out of the filename. OSV screening re-runs against the
resolved version when it differs from the filename-derived one.

The GHSA protection is unchanged: served bytes are still verified against
the SRI declared by the version that owns that tarball name, so a
preserved name cannot smuggle in bytes of another provenance. Tests that
encoded the canonical-reconstruction behavior are updated to assert
basename preservation, with new coverage for non-canonical names.

Also fix a pre-existing compile break in the pnpr server test target:
UserStore::in_memory gained a MaxUsers argument (#12581) but one
test call site was not updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants