Repository navigation
Hide draft and private occurrences from anonymous series reads - #601
Merged
Merged
Conversation
… reads GET /api/events/series/:seriesSlug/events is a public route, but it had no status or visibility filter. Anonymous callers received draft and private occurrences of a series along with the published ones. VisibilityGuard is on the route but never checks it. The guard reads a :slug route param, and the only param on this route is :seriesSlug, so the guard finds no event to check and allows the request. findEventsBySeriesSlug now takes an optional publicOnly flag that adds the same predicate the guard applies to GET /events/:slug, which is published or cancelled status and public or unlisted visibility. The flag is opt-in because series materialization and template lookup call the same method and need to see every occurrence. If they could not see drafts they would create duplicates of occurrences that already exist. The controller sets the flag for callers with no user, so signed-in callers keep the results they get today. The response also carried two fields that anonymous callers should not get. Neither entity has @exclude, so every loaded column was sent, including sourceData with the original poster's DID, handle, CID and rkey, and the internal matrixRoomId. Both are now removed for anonymous callers. sourceId is left alone because the web app shows it as public attribution for events imported from Bluesky.
The controller tests mock findEventsBySeriesSlug, so they check that the controller asks for a restricted query but not that the query applies the restriction. Deleting the two predicates left every test passing while the anonymous leak came back. These tests assert the status and visibility predicates directly, including that draft, pending and private are all excluded, and that an unset publicOnly leaves the query unrestricted so series materialization still sees every occurrence.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
GET /api/events/series/:seriesSlug/eventsis a public route with no status orvisibility filter. Anonymous callers received draft and private occurrences of a
series along with the published ones. This is live today.
VisibilityGuardis applied to the route but never checks it. The guard reads a:slugroute param, and the only param on this route is:seriesSlug, so theguard finds no event to look up and allows the request through.
The response also carried every loaded column, because neither
EventEntitynorGroupEntityhas any@Excludedecorators. That includedsourceData, whichholds the original poster's DID, handle, CID and rkey, and the internal
matrixRoomId.The fix
findEventsBySeriesSlugtakes a new optionalpublicOnlyflag. When set, it addsthe same predicate the guard already applies to
GET /events/:slug: published orcancelled status, and public or unlisted visibility. The predicate runs in SQL
rather than after the fetch, so paging stays correct.
The controller sets the flag when the request has no user. Signed-in callers get
the same results they get today.
For anonymous callers the controller also clears
sourceDataandmatrixRoomId.sourceIdis left in place because the web app renders it as the public ATProtocol attribution link for events imported from Bluesky.
Why the flag is opt-in
About twelve internal callers share this method, including series materialization,
the existence check, and template lookup. Those need to see every occurrence.
If drafts were hidden from them they would treat existing occurrences as missing
and create duplicates. Only the public controller path passes the flag.
VisibilityGuarditself is unchanged. Teaching it about series would mean addinga series lookup and a new branch to a guard that many routes depend on. The query
predicate gives the same result with a smaller blast radius.
Status values
The filter allows published and cancelled, not published alone. That matches the
guard at
visibility.guard.tsand the other public queries inevent-query.service.ts. Without cancelled, a cancelled occurrence would quietlydisappear from a public series list instead of showing as cancelled.
Tests
Seven new tests, at both layers.
Four on the controller cover which callers get the restricted query and that the
two fields are cleared for anonymous callers only.
Three on the query service assert the predicates themselves, including that draft,
pending and private are all excluded, and that leaving
publicOnlyunset keepsthe query unrestricted so materialization still sees every occurrence.
Both layers are needed. The controller tests mock the query method, so on their
own they only prove the controller asks for a restricted query, not that the
query applies the restriction. Deleting the two predicates left all four
controller tests passing with the leak restored. The query service tests fail in
that case.
Not in this change
Anonymous access is what this fixes. Any authenticated user can still read every
occurrence and every field on this endpoint, which is existing behavior and is
tracked separately.
The endpoint also has no date filter, so a caller with no parameters gets the ten
oldest occurrences rather than upcoming ones. That is a real bug but it is not a
leak, and it is tracked separately.