Skip to content

Commit 76fc5c6

Browse files
Unify Vary and add Cache-Control on RDF variants (JavaScriptSolidServer#315)
Browser reloads of mashlib-rendered RDF resources sometimes showed the raw Turtle/JSON-LD body instead of the mashlib data-browser view. Root cause: three different code paths set different Vary values for variants of the same URL: - mashlib HTML wrapper: "Accept" - getVaryHeader (Turtle/JSON-LD via conneg): "Accept, Origin" - getResponseHeaders (default): "Accept, Authorization, Origin" Chromium/Brave's HTTP cache gets confused by Vary mismatches across variants and can serve the cached Turtle body on top-level navigation — the browser then renders it as text. Hard refresh bypasses the cache, which is why it always worked. Fix: - getVaryHeader is the single source of truth. It always emits "Accept, Authorization, Origin" (when mashlib or conneg is on) or "Authorization, Origin" otherwise. Authorization is correct because WAC lets responses vary by authenticated user. - getResponseHeaders / getAllHeaders / getNotFoundHeaders accept mashlibEnabled and route through getVaryHeader. - All headers['Vary'] = 'Accept' overrides in handlers are replaced with the centralized helper. - RDF data variants now carry Cache-Control: "private, no-cache, must-revalidate". ETag stays, so revalidation is a cheap 304. This also closes a real (if narrow) security gap where a cached response from one auth state could leak into another. The mashlib HTML wrapper keeps no-store.
1 parent e7d7dbb commit 76fc5c6

5 files changed

Lines changed: 116 additions & 13 deletions

File tree

src/handlers/container.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export async function handlePost(request, reply) {
141141
connegEnabled
142142
});
143143
headers['Location'] = resourceUrl;
144-
headers['Vary'] = getVaryHeader(connegEnabled);
144+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
145145

146146
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
147147

src/handlers/resource.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMash
2222
*/
2323
const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//' +location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>`;
2424

25+
// Cache-Control for RDF data responses: let clients keep the body but force
26+
// revalidation via ETag on every use. This prevents stale bodies from leaking
27+
// across auth-state changes (WAC) and closes the mashlib render-race window
28+
// where a cached data variant was served on top-level navigation (#315).
29+
const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';
30+
2531
/**
2632
* Inject live reload script into HTML content
2733
*/
@@ -241,7 +247,7 @@ export async function handleGet(request, reply) {
241247
resourceUrl,
242248
connegEnabled
243249
});
244-
headers['Vary'] = 'Accept';
250+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
245251
headers['X-Frame-Options'] = 'DENY';
246252
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
247253
headers['Cache-Control'] = 'no-store';
@@ -276,7 +282,8 @@ export async function handleGet(request, reply) {
276282
resourceUrl,
277283
connegEnabled
278284
});
279-
headers['Vary'] = 'Accept';
285+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
286+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
280287

281288
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
282289
return reply.send(turtleContent);
@@ -294,6 +301,7 @@ export async function handleGet(request, reply) {
294301
resourceUrl,
295302
connegEnabled
296303
});
304+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
297305

298306
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
299307
return reply.send(serializeJsonLd(jsonLd));
@@ -317,7 +325,7 @@ export async function handleGet(request, reply) {
317325
resourceUrl,
318326
connegEnabled
319327
});
320-
headers['Vary'] = 'Accept';
328+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
321329
headers['X-Frame-Options'] = 'DENY';
322330
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
323331
// Don't cache the HTML wrapper - always negotiate fresh
@@ -400,6 +408,7 @@ export async function handleGet(request, reply) {
400408
connegEnabled
401409
});
402410
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
411+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
403412

404413
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
405414
return reply.send(turtleContent);
@@ -430,6 +439,7 @@ export async function handleGet(request, reply) {
430439
connegEnabled
431440
});
432441
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
442+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
433443

434444
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
435445
return reply.send(outputContent);
@@ -458,6 +468,9 @@ export async function handleGet(request, reply) {
458468
connegEnabled
459469
});
460470
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
471+
if (isRdfContentType(actualContentType)) {
472+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
473+
}
461474

462475
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
463476

src/ldp/headers.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* LDP (Linked Data Platform) header utilities
33
*/
44

5-
import { getAcceptHeaders } from '../rdf/conneg.js';
5+
import { getAcceptHeaders, getVaryHeader } from '../rdf/conneg.js';
66

77
const LDP = 'http://www.w3.org/ns/ldp#';
88

@@ -49,7 +49,7 @@ export function getAclUrl(resourceUrl, isContainer) {
4949
* @param {object} options
5050
* @returns {object}
5151
*/
52-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
52+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
5353
// Calculate ACL URL if resource URL provided
5454
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
5555

@@ -58,7 +58,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
5858
'Accept-Patch': 'text/n3, application/sparql-update',
5959
'Accept-Ranges': isContainer ? 'none' : 'bytes',
6060
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
61-
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
61+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
6262
};
6363

6464
// Only set WAC-Allow if explicitly provided (otherwise the auth hook sets it)
@@ -107,9 +107,9 @@ export function getCorsHeaders(origin) {
107107
* @param {object} options
108108
* @returns {object}
109109
*/
110-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
110+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
111111
return {
112-
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
112+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, updatesVia }),
113113
...getCorsHeaders(origin)
114114
};
115115
}
@@ -120,7 +120,7 @@ export function getAllHeaders({ isContainer = false, etag = null, contentType =
120120
* @param {object} options
121121
* @returns {object}
122122
*/
123-
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false }) {
123+
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false, mashlibEnabled = false }) {
124124
// Determine if this would be a container based on URL ending with /
125125
const isContainer = resourceUrl?.endsWith('/') || false;
126126
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
@@ -134,7 +134,7 @@ export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEn
134134
'Accept-Patch': 'text/n3, application/sparql-update',
135135
'Accept-Put': acceptHeaders['Accept-Put'] || 'application/ld+json, */*',
136136
'Allow': 'GET, HEAD, PUT, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
137-
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
137+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
138138
};
139139

140140
if (isContainer && acceptHeaders['Accept-Post']) {

src/rdf/conneg.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,19 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
189189

190190
/**
191191
* Get Vary header value for content negotiation
192-
* Include Accept when conneg or mashlib is enabled (response varies by Accept header)
192+
*
193+
* Must be identical across all variants of a given URL — inconsistent Vary
194+
* across variants confuses browser caches and can cause the wrong variant
195+
* to be served on reload (see #315).
196+
*
197+
* - `Accept` — response body depends on Accept (conneg or mashlib HTML shell)
198+
* - `Authorization` — response body depends on the authenticated user (WAC)
199+
* - `Origin` — CORS headers echo the request's Origin
193200
*/
194201
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
195-
return (connegEnabled || mashlibEnabled) ? 'Accept, Origin' : 'Origin';
202+
return (connegEnabled || mashlibEnabled)
203+
? 'Accept, Authorization, Origin'
204+
: 'Authorization, Origin';
196205
}
197206

198207
/**

test/vary-cache-headers.test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Regression tests for #315 — inconsistent Vary / Cache-Control across
3+
* conneg variants caused stale-render races on browser reload.
4+
*
5+
* What we guarantee now:
6+
* - Every variant of the same URL returns an *identical* Vary header.
7+
* - RDF data variants carry Cache-Control that forces revalidation via
8+
* ETag, so a cached body cannot silently serve across auth changes or
9+
* be picked up on a top-level navigation by mistake.
10+
* - The mashlib HTML wrapper keeps `no-store` (it's a bootstrap template).
11+
*/
12+
13+
import { describe, it, before, after } from 'node:test';
14+
import assert from 'node:assert';
15+
import {
16+
startTestServer,
17+
stopTestServer,
18+
request,
19+
createTestPod
20+
} from './helpers.js';
21+
22+
describe('Vary / Cache-Control consistency (#315)', () => {
23+
before(async () => {
24+
await startTestServer({ conneg: true, mashlibCdn: true });
25+
await createTestPod('varytest');
26+
// Create a JSON-LD resource to exercise all variants.
27+
await request('/varytest/public/card.jsonld', {
28+
method: 'PUT',
29+
headers: { 'Content-Type': 'application/ld+json' },
30+
body: JSON.stringify({
31+
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
32+
'@id': '#me',
33+
'foaf:name': 'Vary Test'
34+
}),
35+
auth: 'varytest'
36+
});
37+
});
38+
39+
after(async () => { await stopTestServer(); });
40+
41+
it('Vary header is identical across all conneg variants of the same URL', async () => {
42+
const accepts = [
43+
'text/html,*/*;q=0.8', // mashlib HTML wrapper
44+
'text/turtle', // Turtle conversion
45+
'application/ld+json' // native JSON-LD
46+
];
47+
const varys = [];
48+
for (const accept of accepts) {
49+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
50+
varys.push({ accept, vary: res.headers.get('vary') });
51+
}
52+
// All three variants must carry the same Vary — inconsistent Vary is
53+
// what confused browser caches into serving the wrong variant.
54+
const unique = new Set(varys.map((v) => v.vary));
55+
assert.strictEqual(unique.size, 1,
56+
`expected identical Vary across variants, got: ${JSON.stringify(varys)}`);
57+
const vary = [...unique][0];
58+
assert.match(vary, /Accept/, 'Vary must include Accept (conneg active)');
59+
assert.match(vary, /Authorization/, 'Vary must include Authorization (WAC)');
60+
assert.match(vary, /Origin/, 'Vary must include Origin (CORS)');
61+
});
62+
63+
it('mashlib HTML wrapper uses Cache-Control: no-store', async () => {
64+
const res = await request('/varytest/public/card.jsonld', {
65+
headers: { Accept: 'text/html,*/*;q=0.8' }
66+
});
67+
assert.match(res.headers.get('content-type') || '', /text\/html/);
68+
assert.strictEqual(res.headers.get('cache-control'), 'no-store');
69+
});
70+
71+
it('RDF data variants force revalidation (no stale bodies across auth changes)', async () => {
72+
for (const accept of ['text/turtle', 'application/ld+json']) {
73+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
74+
const cc = res.headers.get('cache-control');
75+
assert.ok(cc, `expected Cache-Control on ${accept} variant`);
76+
assert.match(cc, /no-cache|no-store/, `Cache-Control "${cc}" must prevent stale reuse (Accept: ${accept})`);
77+
// ETag is preserved so revalidation is cheap (304).
78+
assert.ok(res.headers.get('etag'), `expected ETag on ${accept} variant`);
79+
}
80+
});
81+
});

0 commit comments

Comments
 (0)