Conversation
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).
📝 WalkthroughWalkthroughThe default value of ChangesRegistration disabled by default
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Suggested labels
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
PR Summary by QodoMake pnpr user self-registration opt-in by default Description
Diagram
High-Level Assessment
Files changed (7)
|
Code Review by Qodo
1. Bundled config re-enables signup
|
| /// 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()), | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
pacquet/crates/testing-utils/src/registry.rspnpr/crates/pnpr/config.yamlpnpr/crates/pnpr/src/auth.rspnpr/crates/pnpr/src/auth/tests.rspnpr/crates/pnpr/src/config.rspnpr/crates/pnpr/src/config/tests.rspnpr/crates/pnpr/src/error.rs
| # 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
|
Code review by qodo was updated up to the latest commit efb4952 |
Micro-Benchmark ResultsLinux |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
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
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
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
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
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
]
}
]
} |
|
| Branch | pr/12581 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark 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%) |
|
| Branch | pr/12581 |
| Testbed | pnpr |
⚠️ 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-thresholdsflag.
Click to view all benchmark results
| Benchmark | Latency | milliseconds (ms) |
|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot | 2,807.29 ms |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot | 669.91 ms |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot | 670.05 ms |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot | 2,871.57 ms |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot | 720.02 ms |
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.
Summary
pnprallowed anonymous user self-registration in its default configuration, which — combined with the default$authenticatedpublish policy on**— let any anonymous client create an account, obtain a token, and publish or overwrite arbitrary packages.This makes registration opt-in:
auth.htpasswd.max_usersnow 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".auth.htpasswd.fileis unset) previously hardcodedUnlimitedand ignored the configured cap entirely — so evenmax_users: -1was silently overridden. The cap is now threaded into the in-memory store too.router()/try_router()built in-memory auth that ignoredauth.htpasswd.max_users. They now honor the configured cap (viaAuthState::in_memory_with_max_users), so embedders get the same opt-in default as the productionserve()path.config.yamlis compiled into the binary as the production fallback (ConfigSource::Bundled), so it ships locked down (max_users: -1). A new--max-usersCLI override lets tests/benchmarks enable registration on top of it without a custom YAML; pnpm's e2e registry setup passes--max-users 1000to 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
Checklist
pacquet/port, or the description notes what still needs porting.Written by an agent (Claude Code, claude-opus-4-8).