DR-134 — An RSS feed authenticates by URL-borne token, not by session or header
DR-134 — An RSS feed authenticates by URL-borne token, not by session or header
Section titled “DR-134 — An RSS feed authenticates by URL-borne token, not by session or header”Context
Section titled “Context”app/api/feeds/[workspaceId]/rss/route.ts and its /filtered/ sibling were
unauthenticated: createServiceClient() plus proxy.ts’s isApiRoute check, which
exempts all of /api/** from the auth redirect. id-166 recorded that posture as
intentional and left the mechanism open. Per DR-123 that is evidence of intent at the
time, never of correctness — and the owner has now ruled it an oversight rather than a
design.
Three measurements shaped the ruling.
Session auth would break the feature, but “therefore public” does not follow. Probed
with an empty cookie store, getAuthorisedClient(['admin','editor']) returns
{success:false} and the sibling articles route returns 401. Feed readers cannot
perform a cookie login, so the sibling pattern would 401 every consumer. But this
codebase already authenticates without cookies: lib/mcp/auth.ts:createMcpUserClient
works from an Authorization: Bearer header, and lib/cron-auth.ts gates two more
boundaries on a bearer secret. Cookie auth breaks RSS; authentication does not.
Header credentials are bypassable here, and this is the non-obvious constraint. Both
routes emit Cache-Control: public, max-age=900, s-maxage=900, and they are the only
two API routes in the app emitting public/s-maxage, with no /api/* override in
vercel.json. s-maxage is the shared-CDN directive, so Vercel’s edge can serve these
responses without invoking the handler at all. A credential in a header is not part of
the CDN cache key, so a cached feed could be served to an unauthenticated caller — a
bypass no handler-side code could catch. A URL-borne secret is in the cache key.
The workspace UUID is already a bearer credential, and an unrotatable one. It sits in
/intelligence/[workspaceId] page URLs, browser history and Referrer headers. It
cannot be revoked without destroying the workspace. The /filtered/ feed is the sharper
exposure: relevance_reasoning on rejected articles is the AI explaining the client’s
evaluation criteria, and near-miss scores map the threshold.
Decision
Section titled “Decision”RSS feeds authenticate by workspace-scoped feed token carried in the URL. The routes
move to app/api/intelligence/workspaces/[id]/rss/{route,filtered/route}.ts. The
relocation landed at 28c89ba2f (behaviour unchanged); the token mechanism is specced
under id-166.
Relocation confers no auth by itself — proxy.ts exempts the whole namespace, so every
route there gates itself in its own handler. The two changes are independent.
Alternatives Considered
Section titled “Alternatives Considered”Signed URLs (HMAC over workspace id + expiry). Cheaper — no schema, one env secret. Rejected because expiry is the security model and it fights the use case: a reader polling for months needs a long-lived URL, so you either re-issue constantly or set an expiry so distant it stops being a control. It also gives no revocation.
Public plus rate limiting. Rejected on evidence, not taste. lib/rate-limit.ts is
in-memory and per-serverless-instance — its own header says it targets accidental request
loops, not adversarial abuse — all 10 existing call sites key on user.id, which an
unauthenticated route lacks, and with s-maxage=900 the CDN absorbs the traffic the
limiter would police. It also does not address disclosure, which is the concern the
filtered feed actually raises.
Leave under /api/feeds/ for auditability. The argument was that a separate top-level
namespace makes the unauthenticated surface visible at a glance. Rejected: the property is
a naming convention, not an enforcement point, and it trades a real auth gap for a
readability cue.
Consequences
Section titled “Consequences”components/intelligence/rss-feed-panel.tsxcurrently builds feed URLs client-side fromwindow.location.origin. It must instead render an issued token URL, so the panel gains an issue/revoke surface.- A token table is needed (workspace_id, token hash, label, created/revoked/last_used) with a display-once issue flow.
createServiceClient()stays load-bearing regardless of mechanism: measured on Platform staging,anonholds zero grants onfeed_articles,feed_sources,workspacesandapplication_types, so an anon client returns 404 for every workspace.- Open, and deliberately not assumed: do mainstream feed readers and intranet embed
widgets accept a long-lived credential in the URL path or query, and which can send a
custom
Authorizationheader? Not executed against any real reader. The CDN finding independently favours URL-borne secrets, but the carrier should be settled by test before it is fixed. - Separately surfaced, not ruled here: no table carrying
workspace_idhas an RLS isolation predicate — five areUSING (true)forauthenticatedand the facet tables only assert the parent row exists. Isolation is entirely handler-side. Bounded byanonholding no grants, so the gap is authenticated-to-authenticated.