Skip to content

The /npm registry URL rejects package metadata GET requests #420

Description

@rojo1997

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

  1. Create an npm repository and a content-guarded distribution backed by that repository.

  2. 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>/"
      }
    }
    pnpm publish

    Confirm that the publication succeeds and that the distribution contains the package.

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

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

  5. Configure the scope to use the /npm/... URL:

    @scope:registry=https://pulp.example.com/npm/<distribution>/
  6. Attempt to install the package with pnpm:

    pnpm add @scope/package

    pnpm fails while resolving package metadata because its GET request is handled by the
    publish endpoint and returns 403.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions