Skip to content

fix(articles): stop serving unpublished drafts by slug to anonymous callers - #115

Merged
ssavutu merged 1 commit into
mainfrom
fix/article-detail-draft-exposure
Jul 30, 2026
Merged

fix(articles): stop serving unpublished drafts by slug to anonymous callers#115
ssavutu merged 1 commit into
mainfrom
fix/article-detail-draft-exposure

Conversation

@ssavutu

@ssavutu ssavutu commented Jul 30, 2026

Copy link
Copy Markdown
Member

GET /v1/articles/{slug} is public and returns the full article body, but it looked articles up by slug alone:

db.Select(ctx, conn, "articles", db.ArticleColumns, "`slug` = ?", slug)

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 by OptionalAuth on 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

GetRelatedArticlesBySlug matched 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:

clause rows returned
editor (slug = ?) 1
anonymous (+ pub_date IS NOT NULL AND archived_at IS NULL) 0

So that draft is a 404 for the public and still reachable for an editor.

🤖 Generated with Claude Code

…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>
@ssavutu
ssavutu merged commit 80d5630 into main Jul 30, 2026
5 checks passed
@ssavutu
ssavutu deleted the fix/article-detail-draft-exposure branch July 31, 2026 05:05
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.

1 participant