Skip to content

Sign uploaded blog cover addresses and keep draft covers private - #127

Merged
adamsoffer merged 4 commits into
mainfrom
claude/notion-cover-signing
Oct 13, 2026
Merged

adamsoffer merged 4 commits into
mainfrom
claude/notion-cover-signing

Conversation

@adamsoffer

Copy link
Copy Markdown
Collaborator

Addresses the two review comments Copilot left on #126 just before it merged. Both are about /api/notion-cover, the route that serves a blog post's cover when it was uploaded into Notion rather than taken from the stock library. No published post uses an uploaded cover today, so nothing on livepeer.org changes visibly.

Changes

  • Signed cover addresses. The route used to take any well-formed page ID and ask Notion about it, so random IDs or made-up versions could use up the Notion rate limit that every Notion-backed page shares. Cover addresses now end in <version>.<signature>, an HMAC over the page ID and version (lib/media-signature.ts), and the route checks it before calling Notion. This is what /api/notion-media already does. The signature goes in the path rather than a query string because next/image refuses a local image address with a query string unless images.localPatterns allows it. An out-of-date version still redirects to the current, signed address.
  • No draft covers on livepeer.org. The route served a draft post's uploaded cover to anyone who had its page ID. In production it now refuses drafts, as the blog does (isPublished in lib/blog.ts). Previews and local dev still show drafts for review.

Testing

Called the route directly with a counter on requests to Notion:

Request Result Notion calls
Unsigned 404 0
Forged signature 404 0
Correctly signed Reaches Notion (404 here because that post's cover is from the stock library) 1

/blog, a post page and its share image still render. Typecheck and lint pass.

🤖 Generated with Claude Code

/api/notion-cover took any well-formed page id and asked Notion about it,
so random ids or invented versions could spend the Notion rate limit every
Notion-backed page shares. The address now carries a signature over the
page id and version, in the path as <version>.<signature> (next/image
refuses a local query string without localPatterns), checked before Notion
is called, as /api/notion-media already does.

It also served a draft post's uploaded cover to anyone holding its id. In
production a draft's cover is now refused, as the draft is; previews and
dev still show drafts for review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 13, 2026 15:12
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website Ready Ready Preview Oct 13, 2026 4:09pm UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

URL parsing and publication-status validation remain fail-open in cases that can expose covers or consume Notion capacity.

2 open findings
What changed in this PR

Adds signed Notion blog-cover URLs and production draft-cover protection.

Changes:

  • Adds HMAC signing and verification for uploaded cover paths.
  • Restricts production cover retrieval based on publication status.
  • Redirects stale versions to newly signed URLs.
File Description
lib/​notion.ts Generates signed cover paths and filters unpublished covers.
lib/​media-signature.ts Adds cover-specific HMAC helpers.
app/​api/​notion-cover/​[id]/​[version]/​route.ts Validates signatures before accessing Notion.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/api/notion-cover/[id]/[version]/route.ts
Comment thread lib/notion.ts
The route split its last segment on dots and ignored anything after the
second, so one signed address could be padded into endless distinct ones,
each a cache miss and a Notion call. It now requires the whole segment to
be <12 hex>.<24 hex>.

The production check refused only an explicit Draft, so an empty or
unexpected Status, which the blog reader rejects, still served the cover.
It now serves only an explicit Published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Valid signed URLs still allow query-string and page-ID aliases that bypass cache canonicalization and amplify Notion requests.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread app/api/notion-cover/[id]/[version]/route.ts
A signed address could still be respelled into many: both routes accepted
uppercase and dashed ids, which normalise to the same signature, and
ignored extra query parameters, so each variant was a cache miss and a
Notion call. Both now accept only the address the site writes: the id as
32 lowercase hex, and for media a query of s=<24 hex> alone, for covers no
query at all. Media links are written in that form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Percent-encoded path aliases can still bypass canonical cache-key enforcement and amplify Notion requests.

0 open findings

1 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Enforce canonical raw pathname to prevent cache-key aliases

app/​api/​notion-media/​[id]/​route.ts:46

This route has the same remaining cache-key alias through its path: route params are decoded, so a valid ID with one or more characters percent-encoded still passes BLOCK_ID and the signature while using a distinct URL that can trigger another Notion request. Reject requests whose raw pathname is not the single canonical path emitted by uploadedMediaPath.

  const signature = QUERY.exec(new URL(request.url).search)?.[1] ?? null;
  if (
    !BLOCK_ID.test(id) ||
    !hasNotionCredentials() ||
    !mediaSignatureMatches(id, signature)
Medium severity Reject percent-encoded path aliases before Notion calls

app/​api/​notion-cover/​[id]/​[version]/​route.ts:62

The canonical checks only inspect decoded route params. Percent-encoded path aliases such as encoding any hex character (or the dot in the signed segment) decode to the same id/segment, pass the signature, but remain distinct request URLs and cache keys, so one valid cover URL can still be expanded into many Notion calls. Compare the raw URL pathname with the exact generated path before calling Notion, just as the query string is compared exactly.

🧠 Review effort: Balanced

Next decodes the path before a route sees it, so a percent-encoded
character (%61 for a, %2E for the dot) passed every check on the decoded
params as a distinct address and another Notion call. Both routes now
rebuild the address they expect and compare it with the raw request path
before anything else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The signing, canonicalization, and draft-access changes consistently address the reported security and rate-limit concerns.

0 open findings

🧠 Review effort: Balanced

@rickstaa rickstaa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM thanks

@adamsoffer
adamsoffer merged commit e50303d into main Oct 13, 2026
7 checks passed
@adamsoffer
adamsoffer deleted the claude/notion-cover-signing branch October 13, 2026 16:16

This branch was successfully deployed

1 active deployment
Preview — 6a30c4fb Deployed Oct 13, 2026 by vercel[bot]
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.

3 participants