fix(articles): stop serving unpublished drafts by slug to anonymous callers - #115
Merged
Conversation
…allers
GET /v1/articles/{slug} is public and returns the FULL article body, but it
looked articles up by slug alone. Anyone who knew or guessed the slug of an
unpublished draft or a soft-deleted article got the entire thing: title,
content, authors and SEO fields. This predates #112 -- that PR un-gated the
LIST endpoints and pinned anonymous callers there to published, non-archived
rows, but the single-article endpoint was already public and was left as-is.
Anonymous callers are now restricted to live content. A miss falls through to
the existing 404 rather than a 403, so the response does not reveal that the
slug exists. Editors are identified by OptionalAuth on the route -- the same
mechanism #112 introduced -- and still see drafts and archived articles, which
is what the CMS preview needs.
The Related block on that same response had the same hole and is also fixed.
GetRelatedArticlesBySlug matched purely on vector distance, so a published
article could surface drafts and soft-deleted articles as "related reading" to
an anonymous reader. That one is filtered unconditionally: linking to an
unpublished article is wrong from the CMS too, not just from the public site.
Verified against the dev database, which has 46 real drafts: for the draft slug
19-tips-for-the-class-of-2019-and-2020 the editor clause returns 1 row and the
anonymous clause returns 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
GET /v1/articles/{slug}is public and returns the full article body, but it looked articles up by slug alone:Anyone who knew or guessed the slug of an unpublished draft or a soft-deleted article got the whole thing — title, content, authors, SEO fields.
This predates #112. That PR un-gated the list endpoints and pinned anonymous callers there to published, non-archived rows; the single-article endpoint was already public and was left alone, which I flagged at the time as out of scope. It is the more severe of the two, because the listing was excerpt-only while this returns the entire body.
Fix
Anonymous callers are restricted to
pub_date IS NOT NULL AND archived_at IS NULL. A miss falls through to the existing 404 rather than a 403, so the response does not reveal that the slug exists. Editors are identified byOptionalAuthon the route — the mechanism #112 introduced — and still see drafts and archived articles, which the CMS preview needs.The Related block had the same hole
GetRelatedArticlesBySlugmatched purely on vector distance with no publication filter, so a published article could surface drafts and soft-deleted articles as "related reading" to an anonymous reader. Fixed in the same response path.That one is filtered unconditionally, not just for anonymous callers: linking to an unpublished article is wrong from the CMS preview too, so editors get the same live-content-only list. Flagging it explicitly since it is a behaviour change for logged-in users.
Verification
go build,go vet,go test ./...pass; routes and docs agree on paths and auth for all 44 endpoints.The condition is covered by unit tests both ways, and I checked the SQL against the dev database rather than only the generated string. It has 46 real drafts; for the draft slug
19-tips-for-the-class-of-2019-and-2020:slug = ?)+ pub_date IS NOT NULL AND archived_at IS NULL)So that draft is a 404 for the public and still reachable for an editor.
🤖 Generated with Claude Code