Skip to content

Groups and selectors: raw cursors and npm audits name nothing hidden - #700

Merged
schmitch merged 1 commit into
mainfrom
schmitch/group-selectors-648
Oct 30, 2026
Merged

schmitch merged 1 commit into
mainfrom
schmitch/group-selectors-648

Conversation

@schmitch

Copy link
Copy Markdown
Member

Fixes #648

Raw: next and the directory probe named hidden entries

list_local set a page's cursor to the last entry it walked, hidden or not, and a group passed the smallest member cursor on unchanged. With a selector public/**, GET /repository/<group>/?limit=1&format=json answered {"entries":[],"next":"internal-a/"} (the new test reproduces exactly that on main), and following after= walked every hidden name. The same cursor made a directory of hidden files answer 301 (probe with limit: 1) or 200 with a next.

Now (crates/server/src/files/listing.rs):

  • a page reads on past hidden entries until it holds limit visible ones (plus one, to know whether another page follows), and next is the last visible entry;
  • the walk is bounded for a caller with a selector: at most LISTING_SCAN = 1,000 entries per page, in batches of at least 100. A page that reaches the bound ends at its last visible entry; with none, the listing ends there (documented in docs/raw.md: visible entries behind more than 1,000 hidden ones in a row are not listed);
  • a directory exists for the caller only when it holds a visible entry, so one of hidden entries alone is 404 with and without /, whatever its size;
  • a caller without a selector walks exactly limit + 1 entries, as before.

The group merge is unchanged: each member's more now names a visible entry, so the group's cutoff does too.

npm: an audit through a group ignored selectors

forward_source now collects, besides the reserved prefixes, the caller's package filter on every repository on the way to the proxy (the requested group, nested groups and the proxy itself). The body is filtered when any of them restricts (Withheld::hides); a body that cannot be filtered is refused with 400 as before. A proxy audited directly with a selector on its grant is filtered too.

Tests

common::TestServer::group_callers sets up the issue's three callers over a group of two hosted members (a reader of one member; a selector on the group's grant; a selector on a member's grant), each with what it should see. With it:

  • NuGet (browse.rs): flat container, .nupkg, registration, /browse/packages/{repo}/{id} 404; search and browse list only what's visible, totalHits/total included.
  • npm (npm.rs): packument (both forms), tarball, dist-tags, browse page 404; search and browse lists with counts. A second test audits (bulk and legacy) through nested groups with the selector on the outer group's, the inner group's or the proxy's grant, and directly on the proxy.
  • raw (raw.rs): files and .sha256; root and public/ walked a page of one at a time, checking that the JSON, the Link header and the HTML page never contain a hidden name; hidden directories 404 (with and without /, ?limit=1). Also a hosted and a proxy repository with a selector.
  • Docker (registry.rs): manifest by tag and digest, blob, tag list, referrers; image page, chart page, image list with total.

Old code: the raw test fails on the leaked next and the audit test on private-lib reaching the upstream.

One deviation from the acceptance criteria: Docker answers 403, not 404, under a selector on the group's own grant

Docker decides a name outside a selector of the requested repository's grant by name: the token service grants no pull on it (docs/docker.md: "A registry token grants only what the caller may do when it is issued … content selectors match image names"), and the request is refused with 403 DENIED, whether the image exists or not. So nothing leaks, but it is not a 404. The test asserts that a hidden image answers exactly like an absent image of the same name pattern (<group>/secret/none): 403 in that case, 404 for the other two. Turning it into a 404 would mean issuing pull tokens for names the caller may not read, so I left it. Say if you want it changed.

Checks run

  • cargo test -p forge-server --lib --test raw --test npm --test registry --test browse: 247 + 9 + 16 + 45 + 11 passed
  • cargo clippy -p forge-server -p forge-plugin-api --tests -- -D warnings, cargo fmt --all, cargo xtask changelog --check

Changelog: docs/changes/2.2.0/648.security.md.


Generated by Claude Code

A raw listing set a page's `next` to the last entry it walked, whether
a content selector hid it or not, and a group passed the smallest
member cursor on unchanged: following `after=` walked through every
hidden name of a directory, in the JSON, the `Link` header and the HTML
page alike. The same cursor told a caller that a directory of hidden
files existed (a 301 for the directory probe, a 200 with `next` for a
directory past a page).

A page now reads on past hidden entries until it holds `limit` visible
ones, walking at most 1,000 entries (`LISTING_SCAN`) for a caller with
a selector, and `next` is the last visible one: at the bound the page
ends there, and the listing ends when it found none. A directory exists
for the caller only when it holds a visible entry. A caller without a
selector walks exactly what it did before.

`npm audit` through a group (or straight to a proxy) left out only the
reserved prefixes on the way, so a package a caller's selector hid
still went to the proxy's upstream with the proxy's credentials. A
forward now also leaves out every package the caller's package filter
hides on any repository on the way to the proxy, the proxy's own
included, and refuses (400) a body it cannot filter.

Tests cover a group of two hosted members for NuGet, npm, raw and
Docker, read by a caller who may read only one member, one with a
selector on the group's grant and one with a selector on a member's:
downloads and metadata, search and listings with their counts, the
browse API, raw cursors and directories (also of a hosted and a proxy
repository), and npm audits through nested groups. Docker refuses a
name outside a selector of the requested repository's grant by name
(403, its registry token grants no `pull` on it), so a hidden image
answers as an absent one of the same name pattern does, which the test
checks rather than a 404.

Fixes #648

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJZaXD2mBH9tURsdutM
@schmitch
schmitch added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 956827b Oct 30, 2026
18 checks passed
@schmitch
schmitch deleted the schmitch/group-selectors-648 branch October 30, 2026 05:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Groups and content selectors: a reader sees only the members and packages they may read; raw listings name hidden entries in next

2 participants