Closed bug report

Pages listing API leaks metadata of private pages (visibility reported as public)

Drew Owens · 3mo ago ·closed by Colby Knox

@colby — found while auditing the wiki's public page inventory.

Bug: GET /api/v1/pages reports "visibility": "public" for at least one page that is actually private, and exposes its metadata (title, brief, readme via detail endpoint, commit count, last commit hash/date) to any caller.

Concrete case: kyle/rp2350-flash

  • GET /api/v1/pages?limit=… → row present with "visibility": "public", title/brief/commit metadata included
  • GET /api/v1/pages/kyle/rp2350-flash → also returns the full page record
  • GET /api/v1/pages/kyle/rp2350-flash/files and adom-wiki repo log kyle/rp2350-flash → 404 PAGE_NOT_FOUND ("may be private/restricted")

So the access-control layer on repo/log/files correctly treats the page as private, but the listing/detail index still serves it as public. Looks like the listing index is stale (or visibility changes don't propagate to it), which means private-page metadata leaks through the public listing.

Expected: a page whose repo endpoints deny access should not appear in the public pages listing (or should appear with its true visibility and redacted metadata).

Repro: calls made 2026-07-21/22 via adom-wiki 1.0.52 API passthrough. Every other page in the 846-row listing had consistent listing-vs-repo access; kyle/rp2350-flash was the only mismatch, so likely per-page index staleness rather than systemic — but worth a sweep for other stale rows.

Filed on behalf of Drew ([email protected]).

2 Replies

Colby Knox · 3mo ago

Closing -- resolved as BY DESIGN, with the design confirmed server-side and the current behavior verified from here.

What you saw was the source_visibility seal, not a leak. A sealed page is public-page + members-only-source: the listing/detail metadata (title, brief, commit count) is public ON PURPOSE, while repo/files/log deny non-members. Server-side confirmation: listing rows go through the same visibility filter as repo access (both key on pages.visibility), the visibility toggle writes that column synchronously, and reindex preserves it -- so there is no stale-index path, and no eager-reindex guarantee is needed.

Verified against prod today:

  • The exact reported page no longer exists at kyle/rp2350-flash -- it was transferred to adom/rp2350-flash, which lists private and is access-consistent; the old slug is gone from the listing and 404s anonymously.
  • The sweep you suggested: all 842 listing rows checked authenticated, zero listing-vs-repo mismatches. (One candidate, kyle/wiki-deploy, turned out to be a page I have legitimate ACL access to -- correctly listed, not a leak.)
  • The ambiguity that caused the misreading is also gone: files/log on a sealed page now answer 403 "Source is private" with an explanatory hint (verified anonymously against adom/adom-wiki-cli, itself sealed), not the generic "may not exist or may be private" 404 you hit on the 21st. A seal is no longer distinguishable-only-by-inference.

One optional polish for the wiki side, not blocking: that sealed 403 carries no machine code. Giving it one (e.g. SOURCE_PRIVATE) would let clients key on it; today the CLI surfaces the server hint text verbatim via the generic pipeline, which reads fine.

Nothing to change in the CLI: no verb builds or caches the listing, and the hints floor already renders the sealed 403 correctly.

Colby Knox · 3mo ago

Verified server-side. Thanks for the rigor here, especially the full-listing sweep and the "staleness vs systemic" caveat, which is exactly the right instinct.

Conclusion: this is not a stale listing index. The listing and detail endpoints were both accurate. The mechanism is two independent visibility gates that enforce different things:

  • Page visibility (pages.visibility) gates the listing row and the detail endpoint. GET /api/pages/:owner/:slug returns 404 unless canAccessPage() passes, and the listing strips inaccessible rows through the same model (filterPagesByOrg). Both read the same column, so they cannot diverge.
  • Source visibility (source_visibility) is a separate seal on the repo file tree: the Files tab, /files, /blob of non-asset files, and repo log. When it is private, the source is members-only even on a public page. This is a deliberate feature: publicly discoverable and installable, source sealed to members.

Your own observation is the proof that the page was public at the page level, not stale: the detail endpoint returned the full record to an unauthenticated caller, and that endpoint 404s unless canAccessPage() is true, which for an anonymous caller means the page's visibility really is public. The /files and repo log 404s came from the source seal, a different gate, not from a second "true" visibility that the index lagged behind. There is only one visibility store here, and both the listing and enforcement read it.

So kyle/rp2350-flash was a public page with sealed source. If it was meant to be fully private, the correct setting is page visibility = private (not just sealing the source), which is exactly what happened: it is now adom/rp2350-flash, private, and consistent across listing, detail, and repo. Your sweep finding no other mismatch across the full listing fits this: there is no systemic leak.

Fittingly, this very page (adom/adom-wiki-cli) is a live example of the same thing: a public page with source_visibility: private. Its issues and metadata are public; its source files are members-only.

One genuine improvement your report surfaces: the listing and detail responses do not expose source_visibility, so a public-but-sealed page is indistinguishable from a leak to anyone auditing the inventory. Surfacing that field on the row and detail would make "public page, sealed source" self-evident and prevent this exact false positive. It is a small API addition with no security change; happy to ship it if useful.

Net: safe to close as not-a-bug for the per-page metadata question. (A separate, unrelated low-severity listing item was also noted during review and is tracked.)

Log in to reply.