Skip to content

fix: stop the CDN caching markdown-negotiated docs pages as HTML - #701

Merged
danny-avila merged 1 commit into
mainfrom
fix/docs-markdown-cache-poisoning
Jul 23, 2026
Merged

danny-avila merged 1 commit into
mainfrom
fix/docs-markdown-cache-poisoning

Conversation

@dustinhealy

@dustinhealy dustinhealy commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

/docs/* pages intermittently render as raw, unstyled Markdown text on a white page instead of the normal docs site, no console errors, but the response comes back Content-Type: text/markdown; charset=utf-8.

Root cause

proxy.ts content-negotiates /docs/* requests: an Accept: text/markdown request (sent by LLM/agent tooling, not browsers) gets rewritten to the raw Markdown response (proxy.ts:19-22, proxy.ts:68-72).

/docs/* responses are cached at the shared CDN (Cloudflare, in front of Vercel) with Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800, keyed by URL only. The Markdown-negotiated response was never isolated into its own cache key, so whichever variant a given Cloudflare edge PoP saw first for a URL got served to everyone else hitting that PoP, including regular browsers, until the entry expired.

Reproduction

  1. curl -H "Accept: text/markdown" <docs-url>?cb=<random> -> MISS, cached as text/markdown under that URL's key.
  2. curl -H "Accept: text/html,..." <same-url> -> HIT, still text/markdown, poisoned.
  3. Loading that URL in a real browser renders the raw Markdown as the page.

Reproduced 3/3 via curl and end-to-end in a real Chromium instance via Playwright, screenshots and video attached below.

Before (baseline):
01-baseline-correct-render

Before (poisoned):
02-bug-raw-markdown-render

Video:

03-repro-video.mp4

After (poisoned - but still rendering properly):
01-baseline-correct-render

Fix

Mirror #642: give the Accept: text/markdown-negotiated response its own Cache-Control, split out from the shared /docs/* rule (next.config.mjs), so the CDN never caches it:

  {
    source,
    has: [{ type: 'header', key: 'accept', value: '.*text/markdown.*' }],
    headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
  },

Testing

Verified against a real pnpm build + pnpm start (production mode; next dev disables custom cache headers entirely, so it can't be used to check this):

$ curl -s -D - -o /dev/null "http://localhost:3333/docs/features/mcp"
Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800
Content-Type: text/html; charset=utf-8

$ curl -s -D - -o /dev/null -H "Accept: text/markdown" "http://localhost:3333/docs/features/mcp"
Cache-Control: private, no-store
content-type: text/markdown; charset=utf-8

Not yet verified: this hasn't been exercised against a real Cloudflare deployment (preview/prod), so the closed-loop CDN behavior is an open item.

/docs/* requests with Accept: text/markdown (LLM/agent tooling) get
rewritten by proxy.ts to the raw-markdown response, but that response
shares its cache key with the HTML document since Cloudflare ignores
Vary here. Whichever variant a given edge PoP sees first for a URL
gets cached and served to everyone else hitting that PoP, including
regular browsers, until the entry expires.

This is the same bug class #642 fixed for the RSC flight payload, just
never extended to the markdown content negotiation path. Mirror that
fix: mark Accept: text/markdown responses private, no-store so the
shared CDN never caches them.
@vercel

vercel Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
librechat-ai Ready Ready Preview, Comment Jul 22, 2026 9:17pm

Request Review

@dustinhealy
dustinhealy marked this pull request as ready for review July 22, 2026 21:15
@github-actions

Copy link
Copy Markdown
Contributor

📦 Next.js Bundle Analysis for librechat.ai

This analysis was generated by the Next.js Bundle Analysis action. 🤖

This PR introduced no changes to the JavaScript bundle! 🙌

@graphaelli

Copy link
Copy Markdown

@dustinhealy did you consider including accept in the vary response header per https://developers.cloudflare.com/cache/concepts/vary/ ?

@dustinhealy

dustinhealy commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

@dustinhealy did you consider including accept in the vary response header per https://developers.cloudflare.com/cache/concepts/vary/ ?

I think Cloudflare only partitions its cache key on a header if there's also a Cache Rule configured for it on the zone; the origin returning Vary alone isn't sufficient:

Cache Rules can add other request properties to the cache key ahead of time. The Vary response header lets the origin decide which request headers matter when Cloudflare receives the response.

Cloudflare does not vary every cached response just because Vary is configured in a Cache Rule. The origin response must include a Vary header.

Next.js sends Vary: RSC on every /docs/* response, however Cloudflare still cached whichever variant it saw first, see #642 where it was fixed with private, no-store instead of relying on Vary.

...told apart only by the RSC request header that Next advertises via Vary: rsc. Cloudflare ignores Vary: rsc, so once a Next 16 prefetch populated a cache entry with the flight payload, it served that text/x-component blob to real browser navigations...

source

That implies there's no Cache Rule for rsc on this zone either, so I went with the same approach here rather than assume one exists for accept.

This does mean markdown-negotiated requests never get edge-cached. If a Cache Rule for accept does exist or can be added, that would let those cache too, but probably better suited for a follow-up instead if we choose to pursue.

@danny-avila
danny-avila merged commit 1b63a22 into main Jul 23, 2026
5 checks passed
@danny-avila
danny-avila deleted the fix/docs-markdown-cache-poisoning branch July 23, 2026 03:12

This branch was successfully deployed

1 active deployment
Preview — bdb2f351 Deployed Jul 22, 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