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
-
Do not negotiate on canonical URLs. Serve Markdown only at
.md, which is what the changelog documents and whatllms.txtitself instructs agents to use. Distinct URLs mean distinct cache keys, and the whole class of problem disappears. Agents that followllms.txtlose nothing. -
If negotiation stays, set
Vary: Accepton every variant of the URL, including HTML. This is the minimum correctness fix. Note it is not sufficient on Cloudflare, which ignoresVarybeyondAccept-Encodingoutside Enterprise plans, so it should not be the only change. -
Align the Markdown
max-agewith the HTML page TTL, or defaultcaching:llms:maxAgefar lower. This does not fix the bug but shrinks each poisoning window from an hour to seconds. -
Reconsider
text/plainas a trigger. It is generic enough that non-agent clients hit it by accident. -
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.