Version
pulpcore: 3.116.0
pulp_npm: 0.10.0
- Installation method: Pulp Operator on Kubernetes, using
quay.io/pulp/pulp:3.116.0 and quay.io/pulp/pulp-web:3.116.0
DOMAIN_ENABLED: false
- Node.js version:
24.14.0
- pnpm version:
10.30.3
- npm version: not independently tested
Describe the bug
The native npm publish endpoint makes the package URL under
/npm/<distribution>/ effectively write-only. Publishing works, but npm-compatible
package metadata reads sent to the same registry base URL fail.
The following behavior was observed in a deployed instance:
GET /npm/<distribution>/@scope%2Fpackage
403 Forbidden
Allow: PUT, OPTIONS
A PUT to that same URL, with the same credentials, publishes successfully. This
confirms that the credentials are valid for publishing; the GET is rejected separately
by the method-specific access policy. In the observed deployment, the publishing account
has all npm model permissions and belongs to the group holding the content guard's
download role.
Reading the same package from Pulp's content-app succeeds:
GET /pulp/content/<distribution>/@scope%2Fpackage
200 OK
The successful response contains the expected package metadata, including versions,
dist-tags, and tarball URLs. Pointing pnpm at the content-app URL also allows the package
to be installed successfully. npm itself was not independently tested. The result
indicates that the repository content and metadata are available and that the failure
occurs when the request is routed through the /npm/... endpoint.
This split is problematic for standard registry configuration. npm associates a scope
with one registry URL and uses that registry for both installation and publication:
https://docs.npmjs.com/misc/scope/#associating-a-scope-with-a-registry
The publishConfig.registry field can override the registry specifically at publication
time, but requiring it means that /npm/... cannot itself be used as a normal registry
base URL:
https://docs.npmjs.com/cli/configuring-npm/package-json/#publishconfig
To Reproduce
-
Create an npm repository and a content-guarded distribution backed by that repository.
-
Configure publishConfig.registry with the native registry URL and publish a scoped
package such as @scope/package with pnpm:
{
"publishConfig": {
"registry": "https://pulp.example.com/npm/<distribution>/"
}
}
Confirm that the publication succeeds and that the distribution contains the package.
-
Query the metadata through the native publish/registry URL. For a deployment without
domains:
REGISTRY='https://pulp.example.com/npm/<distribution>/'
curl --path-as-is -i "${REGISTRY}@scope%2Fpackage"
If the registry requires authentication, use the same credentials configured for the
npm client, for example:
curl --path-as-is -i -u '<user>:<password>' \
"${REGISTRY}@scope%2Fpackage"
Observed response:
HTTP/1.1 403 Forbidden
Allow: PUT, OPTIONS
The same credentials succeed for pnpm publish against this URL.
-
Query the same package through the content-app:
CONTENT='https://pulp.example.com/pulp/content/<distribution>/'
curl --path-as-is -i "${CONTENT}@scope%2Fpackage"
Observed response: 200 OK with valid npm package metadata.
-
Configure the scope to use the /npm/... URL:
@scope:registry=https://pulp.example.com/npm/<distribution>/
-
Attempt to install the package with pnpm:
pnpm fails while resolving package metadata because its GET request is handled by the
publish endpoint and returns 403.
-
Change the configured registry to the content-app URL:
@scope:registry=https://pulp.example.com/pulp/content/<distribution>/
The same install command succeeds.
For a domain-enabled deployment, insert the domain after /npm/ and
/pulp/content/, for example:
https://pulp.example.com/npm/default/<distribution>/
https://pulp.example.com/pulp/content/default/<distribution>/
Expected behavior
GET /npm/<distribution>/<package> should return the package metadata required by npm
clients. It may serve the response directly or redirect to the equivalent content-app
URL, provided that authentication and content-guard enforcement are preserved.
The /npm/... prefix already answers GET requests for -/ping and -/whoami, so it is
already treated as a registry base URL and not only as a publication target. The registry
base URL accepted by native publishing should therefore also support the metadata reads
that npm clients send to a configured registry.
Impact
The /npm/... package route cannot currently serve as a complete npm registry endpoint:
metadata reads fail even though publication and content-app delivery work independently.
This directly affects metadata-dependent operations such as package installation and
package inspection. In the observed environment, pnpm add fails when the native publish
URL is configured as the registry for a scope, as shown in the project documentation.
Other npm registry operations may also be affected but were not independently tested.
The issue has been observed with pulp_npm 0.10.0. Source history shows that the
conflicting route is also present in 0.8.0 and 0.9.0; those versions have not been
independently tested as part of this report.
Workaround
Configure reads through the content-app:
@scope:registry=https://pulp.example.com/pulp/content/<distribution>/
Use publishConfig.registry to keep publication on the native publish endpoint:
{
"publishConfig": {
"registry": "https://pulp.example.com/npm/<distribution>/"
}
}
Additional context
Source-level root cause
The package route is registered without regard to the HTTP method in
pulp_npm/app/urls.py, lines 6–18:
if settings.DOMAIN_ENABLED:
_base = r"npm/(?P<pulp_domain>[-a-zA-Z0-9_]+)/(?P<path>.+?)/"
else:
_base = r"npm/(?P<path>.+?)/"
# Matches both scoped (@scope%2Fname) and unscoped package names in a single group.
_pkg = r"(?P<package_name>@[^/]+%2[Ff][^/]+|@[^/]+/[^/]+|[^/@][^/]*)"
urlpatterns = [
re_path(rf"^{_base}-/ping$", NpmPingView.as_view()),
re_path(rf"^{_base}-/whoami$", NpmWhoamiView.as_view()),
re_path(rf"^{_base}{_pkg}$", NpmPublishView.as_view()),
]
The _pkg pattern matches both scoped and unscoped package metadata URLs, for both the
domain-enabled and domain-disabled forms of _base. Every request to a package URL under
/npm/..., regardless of method, is therefore dispatched to NpmPublishView, which is
the only view registered for that path.
NpmPublishView, lines 38–146
implements only put(). Its RBAC policy also allows only the put action:
DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["put"],
"principal": "authenticated",
"effect": "allow",
"condition": "npm_has_repository_perm:npm.modify_npmrepository",
},
],
}
initial() derives the action from the request method before applying the access policy.
A GET therefore has no matching allow statement and is rejected. The view's available
methods explain the Allow: PUT, OPTIONS header.
The content-app already has the corresponding read behavior.
NpmDistribution.content_handler(), lines 109–162
selects the package versions and builds the npm metadata response when the request reaches
/pulp/content/....
The conflicting route was introduced by
#401, “enable support for yarn/npm native publishing”
and first released in pulp_npm 0.8.0. It is therefore not new in 0.10.0. The RBAC
changes in #414, released in 0.10.0, added
the PUT-only access policy responsible for the observed 403 response. Since
NpmPublishView has implemented only put() since 0.8.0, package reads through
/npm/... were presumably already failing in 0.8.0 and 0.9.0 with
405 Method Not Allowed, and 0.10.0 only changed the status code; those versions have
not been tested as part of this report.
The 0.10.0 changelog describes the RBAC addition but does not mention the effect on GET
requests to package URLs:
https://github.com/pulp/pulp_npm/blob/0.10.0/CHANGES.md?plain=1#L11-L19
Test coverage gap
The current documentation also exposes the split: it configures /npm/... for native
publishing and scope registry settings, but instructs users to query and install packages
through /pulp/content/...:
https://github.com/pulp/pulp_npm/blob/0.10.0/docs/user/guides/npm-publish.md?plain=1#L104-L139
Possible implementation approach (not yet tested in pulp_npm)
One possible fix is to add GET handling to the /npm/.../<package> view and redirect
those requests to the canonical content-app package URL. The access policy would need a
GET statement, while PUT would continue to require
npm.modify_npmrepository.
This would keep package metadata generation and content-guard enforcement in the
content-app. It also follows an existing pattern in pulp_python. Its
SimpleView, lines 250–267
allows list and retrieve for principal * while restricting create to authenticated
users holding python.modify_pythonrepository, and its
retrieve(), lines 353–363
redirects to the content-app when
should_redirect(), lines 134–139
confirms the content-app has a publication to serve, and generates the response in the
view otherwise.
This is a suggested direction based on source inspection and the pulp_python precedent;
it has not yet been implemented or exercised against pulp_npm.
Whichever implementation is selected should add functional coverage for:
- scoped and unscoped package GET requests through
/npm/...;
- domains enabled and disabled;
- unguarded and content-guarded distributions;
- preservation of PUT authorization and publishing behavior;
- pnpm installation using the
/npm/... registry URL, plus npm coverage if the project
supports both clients in its functional test environment.
Version
pulpcore:3.116.0pulp_npm:0.10.0quay.io/pulp/pulp:3.116.0andquay.io/pulp/pulp-web:3.116.0DOMAIN_ENABLED:false24.14.010.30.3Describe the bug
The native npm publish endpoint makes the package URL under
/npm/<distribution>/effectively write-only. Publishing works, but npm-compatiblepackage metadata reads sent to the same registry base URL fail.
The following behavior was observed in a deployed instance:
A
PUTto that same URL, with the same credentials, publishes successfully. Thisconfirms that the credentials are valid for publishing; the GET is rejected separately
by the method-specific access policy. In the observed deployment, the publishing account
has all npm model permissions and belongs to the group holding the content guard's
download role.
Reading the same package from Pulp's content-app succeeds:
The successful response contains the expected package metadata, including versions,
dist-tags, and tarball URLs. Pointing pnpm at the content-app URL also allows the package
to be installed successfully. npm itself was not independently tested. The result
indicates that the repository content and metadata are available and that the failure
occurs when the request is routed through the
/npm/...endpoint.This split is problematic for standard registry configuration. npm associates a scope
with one registry URL and uses that registry for both installation and publication:
https://docs.npmjs.com/misc/scope/#associating-a-scope-with-a-registry
The
publishConfig.registryfield can override the registry specifically at publicationtime, but requiring it means that
/npm/...cannot itself be used as a normal registrybase URL:
https://docs.npmjs.com/cli/configuring-npm/package-json/#publishconfig
To Reproduce
Create an npm repository and a content-guarded distribution backed by that repository.
Configure
publishConfig.registrywith the native registry URL and publish a scopedpackage such as
@scope/packagewith pnpm:{ "publishConfig": { "registry": "https://pulp.example.com/npm/<distribution>/" } }Confirm that the publication succeeds and that the distribution contains the package.
Query the metadata through the native publish/registry URL. For a deployment without
domains:
If the registry requires authentication, use the same credentials configured for the
npm client, for example:
Observed response:
The same credentials succeed for
pnpm publishagainst this URL.Query the same package through the content-app:
Observed response:
200 OKwith valid npm package metadata.Configure the scope to use the
/npm/...URL:@scope:registry=https://pulp.example.com/npm/<distribution>/Attempt to install the package with pnpm:
pnpm fails while resolving package metadata because its GET request is handled by the
publish endpoint and returns
403.Change the configured registry to the content-app URL:
@scope:registry=https://pulp.example.com/pulp/content/<distribution>/The same install command succeeds.
For a domain-enabled deployment, insert the domain after
/npm/and/pulp/content/, for example:Expected behavior
GET /npm/<distribution>/<package>should return the package metadata required by npmclients. It may serve the response directly or redirect to the equivalent content-app
URL, provided that authentication and content-guard enforcement are preserved.
The
/npm/...prefix already answers GET requests for-/pingand-/whoami, so it isalready treated as a registry base URL and not only as a publication target. The registry
base URL accepted by native publishing should therefore also support the metadata reads
that npm clients send to a configured registry.
Impact
The
/npm/...package route cannot currently serve as a complete npm registry endpoint:metadata reads fail even though publication and content-app delivery work independently.
This directly affects metadata-dependent operations such as package installation and
package inspection. In the observed environment,
pnpm addfails when the native publishURL is configured as the registry for a scope, as shown in the project documentation.
Other npm registry operations may also be affected but were not independently tested.
The issue has been observed with
pulp_npm 0.10.0. Source history shows that theconflicting route is also present in
0.8.0and0.9.0; those versions have not beenindependently tested as part of this report.
Workaround
Configure reads through the content-app:
@scope:registry=https://pulp.example.com/pulp/content/<distribution>/Use
publishConfig.registryto keep publication on the native publish endpoint:{ "publishConfig": { "registry": "https://pulp.example.com/npm/<distribution>/" } }Additional context
Source-level root cause
The package route is registered without regard to the HTTP method in
pulp_npm/app/urls.py, lines 6–18:The
_pkgpattern matches both scoped and unscoped package metadata URLs, for both thedomain-enabled and domain-disabled forms of
_base. Every request to a package URL under/npm/..., regardless of method, is therefore dispatched toNpmPublishView, which isthe only view registered for that path.
NpmPublishView, lines 38–146implements only
put(). Its RBAC policy also allows only theputaction:initial()derives the action from the request method before applying the access policy.A GET therefore has no matching allow statement and is rejected. The view's available
methods explain the
Allow: PUT, OPTIONSheader.The content-app already has the corresponding read behavior.
NpmDistribution.content_handler(), lines 109–162selects the package versions and builds the npm metadata response when the request reaches
/pulp/content/....The conflicting route was introduced by
#401, “enable support for yarn/npm native publishing”
and first released in
pulp_npm 0.8.0. It is therefore not new in0.10.0. The RBACchanges in #414, released in
0.10.0, addedthe PUT-only access policy responsible for the observed
403response. SinceNpmPublishViewhas implemented onlyput()since0.8.0, package reads through/npm/...were presumably already failing in0.8.0and0.9.0with405 Method Not Allowed, and0.10.0only changed the status code; those versions havenot been tested as part of this report.
The
0.10.0changelog describes the RBAC addition but does not mention the effect on GETrequests to package URLs:
https://github.com/pulp/pulp_npm/blob/0.10.0/CHANGES.md?plain=1#L11-L19
Test coverage gap
test_npm_publish.pycovers package PUT requests and GET requests for
/-/pingand/-/whoami, but does notGET a package through
/npm/....distribution.base_url, which points to the content-app.For example, see
test_download_policies.py, lines 51–57./-/ping, but not against a packageURL:
test_rbac.py, lines 434–461.The current documentation also exposes the split: it configures
/npm/...for nativepublishing and scope registry settings, but instructs users to query and install packages
through
/pulp/content/...:https://github.com/pulp/pulp_npm/blob/0.10.0/docs/user/guides/npm-publish.md?plain=1#L104-L139
Possible implementation approach (not yet tested in
pulp_npm)One possible fix is to add GET handling to the
/npm/.../<package>view and redirectthose requests to the canonical content-app package URL. The access policy would need a
GET statement, while PUT would continue to require
npm.modify_npmrepository.This would keep package metadata generation and content-guard enforcement in the
content-app. It also follows an existing pattern in
pulp_python. ItsSimpleView, lines 250–267allows
listandretrievefor principal*while restrictingcreateto authenticatedusers holding
python.modify_pythonrepository, and itsretrieve(), lines 353–363redirects to the content-app when
should_redirect(), lines 134–139confirms the content-app has a publication to serve, and generates the response in the
view otherwise.
This is a suggested direction based on source inspection and the
pulp_pythonprecedent;it has not yet been implemented or exercised against
pulp_npm.Whichever implementation is selected should add functional coverage for:
/npm/...;/npm/...registry URL, plus npm coverage if the projectsupports both clients in its functional test environment.