Markdown content negotiation on canonical URLs poisons shared caches, serving raw Markdown to readers

Affects: Ghost 6.x with the GEO / llms.txt setting enabled (Settings → Meta data) Confirmed present in: v6.59.0 (source below), observed in production on v6.52

Issue summary

When llms_enabled is on, Ghost content-negotiates Markdown on the canonical post/page URL, not only on the documented .md suffix. A request to /some-post/ carrying Accept: text/markdown or Accept: text/plain returns Content-Type: text/markdown.

The Markdown response sets Vary: Accept, but the HTML response for the same URL does not. Combined with a Markdown max-age of 3600 against a typical HTML max-age of 20, any shared cache that does not split on Vary: Accept will store the Markdown response as the canonical page and serve it to human visitors for up to an hour.

Readers see a wall of raw Markdown where the article should be.

@jannis suggested the CDN rule fix for Cloudflare in the replies to the announcement

Steps to reproduce

On any Ghost site with the setting enabled, behind a CDN that does not honour Vary: Accept (Cloudflare ignores Vary other than Accept-Encoding outside Enterprise):

# 1. Any non-browser client asks for plain text at the canonical URL

curl -sI -H 'Accept: text/plain' https://example.com/some-post/

# → content-type: text/markdown; charset=utf-8

# → cache-control: public, max-age=3600

# 2. An ordinary browser request to the same URL, moments later

curl -s -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \

https://example.com/some-post/ | head -1

# → "> ## Content Index" (raw Markdown, cf-cache-status: HIT)

Origin behaviour alone (bypassing the CDN) is reproducible anywhere:

curl -sI -H 'Accept: text/markdown' https://example.com/some-post/   # → text/markdown

curl -sI -H 'Accept: text/plain' https://example.com/some-post/ # → text/markdown

Expected

The canonical URL returns HTML. Markdown is served at the documented .md path.

Actual

The canonical URL returns Markdown to any client whose Accept header prefers text/markdown or text/plain, and that response is cacheable under the canonical URL’s cache key.

Why this is hard for publishers to catch

The damage is edge-local. The person who reports it and the person who shared the link hit different POPs, so the publisher refreshes, sees a perfectly normal article, and cannot reproduce it. The reader who sees raw Markdown almost always just leaves rather than reporting it.

The lifetime asymmetry means the broken state is not a rare race — it is the favoured outcome. Markdown holds a cache slot for 3600s; HTML holds it for 20s. Once any crawler, script, or agent touches a POP, that POP serves Markdown to every human for the rest of the hour.

Root cause

1. Trigger is broader than documented. ghost/core/core/frontend/services/llms/markdown.js:

function getAcceptedMarkdownContentType(req) {

const acceptHeader = (req.get('Accept') || '').toLowerCase();

if (!acceptHeader.includes('text/markdown') && !acceptHeader.includes('text/plain')) {

return null;

}

const preferredType = req.accepts(['text/markdown', 'text/plain', 'text/html']);

text/plain as a trigger is surprising. It is a generic type sent by plenty of clients that are not AI tooling. The changelog announcing the feature documents only the .md suffix; canonical-URL negotiation is not mentioned at all.

Credit where due: the q-value handling here is correct. A client sending text/html at equal or higher priority gets HTML, so real browser navigation never triggers Markdown on its own. Browsers only ever inherit it from a cache.

2. Asymmetric Vary. In ghost/core/core/frontend/services/routing/controllers/entry/markdown.ts, serveAcceptsRequest() calls res.vary('Accept'). Nothing on the HTML path does. Observed headers for one URL:

HTML     → vary: Accept-Encoding

Markdown → vary: Accept, Accept-Encoding

A cache that stores the HTML variant first records no Accept dependency, so it will happily serve that entry to a Markdown-preferring client, and vice versa.

3. Lifetime asymmetry. Same file: res.set('Cache-Control', 'public, max-age=' + config.get('caching:llms:maxAge')), defaulting to 3600 in defaults.json. Post HTML is typically max-age=20. The Markdown variant therefore outlives the HTML variant by 180x in any shared cache.

Suggested fixes, roughly in order of value

  1. Do not negotiate on canonical URLs. Serve Markdown only at .md, which is what the changelog documents and what llms.txt itself instructs agents to use. Distinct URLs mean distinct cache keys, and the whole class of problem disappears. Agents that follow llms.txt lose nothing.

  2. If negotiation stays, set Vary: Accept on every variant of the URL, including HTML. This is the minimum correctness fix. Note it is not sufficient on Cloudflare, which ignores Vary beyond Accept-Encoding outside Enterprise plans, so it should not be the only change.

  3. Align the Markdown max-age with the HTML page TTL, or default caching:llms:maxAge far lower. This does not fix the bug but shrinks each poisoning window from an hour to seconds.

  4. Reconsider text/plain as a trigger. It is generic enough that non-agent clients hit it by accident.

  5. At minimum, document the CDN requirement next to the setting, so operators know that enabling it behind a Vary-collapsing cache will serve Markdown to readers.

Notes

Reproduced and fixed in production on a live publication. Interim mitigation for anyone hitting this on Cloudflare, a Cache Rule with action Bypass cache:

(any(http.request.headers["accept"][*] contains "text/markdown")) or (any(http.request.headers["accept"][*] contains "text/plain"))

Header name must be lowercase. Both clauses are required — matching only text/markdown leaves the text/plain path fully exploitable. Purge the cache after adding the rule, not before, or the purge is immediately re-poisoned. Verified after: agents still receive text/markdown (uncached), browsers receive HTML on every request, and a replayed poisoning attempt fails on both vectors.

A Ghost host has already published a workaround for this on the Ghost forum, which suggests hosting providers are patching around it individually rather than it being known upstream.

Fixed in Stop Accept-header markdown on HTML permalinks by louisghost · Pull Request #30259 · TryGhost/Ghost · GitHub