diff --git a/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js b/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js index 52f30417d2..e72c472698 100644 --- a/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +++ b/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js @@ -86,9 +86,10 @@ function initSystemSchemaPaths(schema, table) { schema = schema.toString(); table = table.toString(); - // Check to see if there are any CLI or env args related to schema/table path - const args = process.env; - Object.assign(args, minimist(process.argv)); + // Check to see if there are any CLI or env args related to schema/table path. + // Merge CLI args over env vars WITHOUT mutating process.env: assigning into process.env + // permanently clobbered real env vars (e.g. AUTHENTICATION_AUTHORIZELOCAL) with CLI values. + const args = Object.assign({}, process.env, minimist(process.argv)); const schemaConfJson = args[CONFIG_PARAMS.DATABASES.toUpperCase()]; if (schemaConfJson) { diff --git a/dataLayer/schemaDescribe.ts b/dataLayer/schemaDescribe.ts index e9c61e4603..76cf747c6c 100644 --- a/dataLayer/schemaDescribe.ts +++ b/dataLayer/schemaDescribe.ts @@ -204,6 +204,7 @@ async function descTable(describeTableObject: any, attrPerms?: any) { if (tableObj.replicate !== undefined) tableResult.replicate = tableObj.replicate; if (tableObj.expirationMS !== undefined) tableResult.expiration = tableObj.expirationMS / 1000 + 's'; if (tableObj.sealed !== undefined) tableResult.sealed = tableObj.sealed; + if (tableObj.cacheControl != null) tableResult.cacheControl = tableObj.cacheControl; if ((tableObj as any).sources?.length > 0) tableResult.sources = (tableObj as any).sources .map((source: any) => source.name) diff --git a/integrationTests/server/fixtures/http-cache-headers/config.yaml b/integrationTests/server/fixtures/http-cache-headers/config.yaml new file mode 100644 index 0000000000..efffc0833f --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/config.yaml @@ -0,0 +1,5 @@ +graphqlSchema: + files: '*.graphql' +jsResource: + files: resources.js +rest: true diff --git a/integrationTests/server/fixtures/http-cache-headers/resources.js b/integrationTests/server/fixtures/http-cache-headers/resources.js new file mode 100644 index 0000000000..fc69c785ce --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/resources.js @@ -0,0 +1,51 @@ +const { LabeledDoc, CachedDoc, PlainCachedDoc } = tables; + +export class CachedSource extends Resource { + async get() { + const id = this.getId(); + return { id, value: `sourced ${id}` }; + } +} +CachedDoc.sourcedFrom(CachedSource); +PlainCachedDoc.sourcedFrom(CachedSource); + +// anonymous-readable exports of the tables above +export class PublicLabeled extends LabeledDoc { + allowRead() { + return true; + } +} +export class PublicCached extends CachedDoc { + allowRead() { + return true; + } +} +export class PublicPlainCached extends PlainCachedDoc { + allowRead() { + return true; + } +} + +// resource that sets its own Cache-Control (RFC 9111 shared-cache opt-in) +export class SelfCaching extends Resource { + allowRead() { + return true; + } + get() { + this.getContext().responseHeaders.set('Cache-Control', 'public, max-age=10'); + return { hello: 'world' }; + } +} + +// sets a shared-cache opt-in but then rejects the credentials — the 401 must NOT be shared-cacheable +export class PublicButDenied extends Resource { + allowRead() { + return true; + } + get() { + this.getContext().responseHeaders.set('Cache-Control', 'public, max-age=30'); + const error = new Error('Unauthorized'); + error.statusCode = 401; + throw error; + } +} diff --git a/integrationTests/server/fixtures/http-cache-headers/schema.graphql b/integrationTests/server/fixtures/http-cache-headers/schema.graphql new file mode 100644 index 0000000000..7ba79af80f --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/schema.graphql @@ -0,0 +1,19 @@ +type SecretDoc @table @export { + id: ID @primaryKey + value: String +} + +type LabeledDoc @table(cacheControl: "public, max-age=45") @export { + id: ID @primaryKey + value: String +} + +type CachedDoc @table(expiration: 120, cacheControl: "public, s-maxage=120") @export { + id: ID @primaryKey + value: String +} + +type PlainCachedDoc @table(expiration: 120) @export { + id: ID @primaryKey + value: String +} diff --git a/integrationTests/server/http-cache-headers.test.ts b/integrationTests/server/http-cache-headers.test.ts new file mode 100644 index 0000000000..582055cc90 --- /dev/null +++ b/integrationTests/server/http-cache-headers.test.ts @@ -0,0 +1,171 @@ +/** + * HTTP cache-header hardening and defaults (#1518, #1565). + * + * Covers the three tiers of Harper's Cache-Control/Vary policy: + * + * 1. `Vary: Origin` on CORS-enabled responses (reflected ACAO and preflight) so a shared + * cache/CDN cannot serve one origin's CORS headers to another (#1518). + * 2. The identity floor: authenticated (and 401) responses get `Cache-Control: private, + * no-cache` + `Vary: Authorization` (+ `Cookie` with sessions) unless the app explicitly + * opted into shared caching with `public`/`s-maxage` (#1565). + * 3. Declared shared-cache headers for anonymous reads: `@table(cacheControl: "...")` (or a + * resource `static cacheControl`), so CDNs like Akamai can cache public responses. The + * declaration is required — anonymous readability alone never emits shared-cache headers. + * + * The fixture app is installed before boot (so every thread, including main, processes the + * schema), and `AUTHENTICATION_AUTHORIZELOCAL=false` keeps unauthenticated loopback requests + * genuinely anonymous — overriding the harness's authorize-local default. + */ +import { suite, test, before, after } from 'node:test'; +import { ok, strictEqual } from 'node:assert'; +import { resolve } from 'node:path'; +import { setupHarperWithFixture, teardownHarper } from '@harperfast/integration-testing'; +// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine +import { createApiClient } from '../apiTests/utils/client.mjs'; +import request from 'supertest'; + +const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures/http-cache-headers'); + +function varyTokens(res: any): string[] { + const vary = res.headers['vary'] ?? ''; + return vary + .toLowerCase() + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); +} + +suite('HTTP cache headers (#1518, #1565)', (ctx: any) => { + let client: any; + let restURL: string; + + before(async () => { + await setupHarperWithFixture(ctx, FIXTURE_PATH, { + config: { + http: { cors: true, corsAccessList: ['*'] }, + }, + env: { AUTHENTICATION_AUTHORIZELOCAL: 'false' }, + }); + client = createApiClient(ctx.harper); + restURL = ctx.harper.httpURL; + + // seed one record per table + for (const [path, id] of [ + ['/SecretDoc/', 'sec1'], + ['/LabeledDoc/', 'lab1'], + ]) { + await request(restURL) + .put(`${path}${id}`) + .set(client.headers) + .send({ id, value: 'seed' }) + .expect((r: any) => ok([200, 201, 204].includes(r.status), `seed PUT ${path}${id} → ${r.status}: ${r.text}`)); + } + }); + + after(async () => { + await teardownHarper(ctx); + }); + + // ── #1518: Vary: Origin ──────────────────────────────────────────────── + + test('CORS GET with Origin: ACAO reflected and Vary includes Origin', async () => { + const res = await request(restURL) + .get('/SecretDoc/sec1') + .set(client.headers) + .set('Origin', 'https://a.example.com') + .expect(200); + strictEqual(res.headers['access-control-allow-origin'], 'https://a.example.com'); + ok(varyTokens(res).includes('origin'), `Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('OPTIONS preflight carries Vary: Origin', async () => { + const res = await request(restURL) + .options('/SecretDoc/sec1') + .set('Origin', 'https://a.example.com') + .set('Access-Control-Request-Method', 'GET'); + strictEqual(res.headers['access-control-allow-origin'], 'https://a.example.com'); + ok(varyTokens(res).includes('origin'), `preflight Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('CORS-enabled response without an Origin header still varies on Origin', async () => { + // the no-Origin variant (no ACAO) is itself origin-dependent — a cache serving it to a + // CORS request would break that request + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + ok(varyTokens(res).includes('origin'), `Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('Vary appends to serializer tokens instead of clobbering them', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + const tokens = varyTokens(res); + for (const expected of ['accept', 'accept-encoding', 'origin', 'authorization']) { + ok(tokens.includes(expected), `Vary should include ${expected}, got: ${res.headers['vary']}`); + } + }); + + // ── #1565: identity floor ────────────────────────────────────────────── + + test('authenticated read gets private, no-cache + Vary: Authorization, Cookie', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + const tokens = varyTokens(res); + ok(tokens.includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + ok(tokens.includes('cookie'), `Vary should include Cookie (sessions enabled), got: ${res.headers['vary']}`); + }); + + test('401 for anonymous access to a protected table carries the private floor', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').expect(401); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + ok(varyTokens(res).includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + }); + + test('401 with an app-set public Cache-Control is still forced private (no opt-in on rejection)', async () => { + const res = await request(restURL).get('/PublicButDenied/x').set(client.headers).expect(401); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + ok(varyTokens(res).includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + }); + + test('authenticated read of a cacheControl-labeled table still gets the private floor', async () => { + // the @table(cacheControl:) default applies to anonymous reads only; an authenticated + // response must not inherit the shared-cache policy + const res = await request(restURL).get('/PublicLabeled/lab1').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + }); + + test('app-set public Cache-Control on an authenticated response is trusted (RFC 9111 opt-in)', async () => { + const res = await request(restURL).get('/SelfCaching/x').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=10'); + ok(!varyTokens(res).includes('authorization'), `opted-in response should not vary on Authorization`); + }); + + // ── #1565 converse: anonymous shared-cache defaults ──────────────────── + + test('anonymous read emits @table(cacheControl:) declaration', async () => { + const res = await request(restURL).get('/PublicLabeled/lab1').expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=45'); + ok(!varyTokens(res).includes('authorization'), `anonymous response should not vary on Authorization`); + }); + + test('anonymous read of a caching table with a declared cacheControl emits it', async () => { + const res = await request(restURL).get('/PublicCached/c1').expect(200); + strictEqual(res.headers['cache-control'], 'public, s-maxage=120'); + }); + + test('anonymous read of a caching table WITHOUT a declaration emits no Cache-Control', async () => { + // shared-cache headers require the explicit declaration — a TTL alone is not an opt-in + const res = await request(restURL).get('/PublicPlainCached/c1').expect(200); + strictEqual(res.headers['cache-control'], undefined); + }); + + test('anonymous read with app-set Cache-Control keeps the app value', async () => { + const res = await request(restURL).get('/SelfCaching/x').expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=10'); + }); + + test('describe_table surfaces cacheControl', async () => { + const res = await client + .req() + .send({ operation: 'describe_table', database: 'data', table: 'LabeledDoc' }) + .expect(200); + strictEqual(res.body.cacheControl, 'public, max-age=45'); + }); +}); diff --git a/resources/Table.ts b/resources/Table.ts index 41d476b4a2..1e449dfc1d 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -203,6 +203,7 @@ export function makeTable(options) { replicate, description, hidden, + cacheControl, } = options; let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options; evictionMs ??= 0; @@ -306,6 +307,8 @@ export function makeTable(options) { static description = description; static properties = properties; static hidden = hidden; + // default `Cache-Control` for anonymous REST reads (from `@table(cacheControl: "...")`), see REST.ts + static cacheControl = cacheControl; static outputSchemas: { [verb: string]: JsonSchemaFragment } | undefined; static mcp: { annotations?: { [verb: string]: any } } | undefined; static replicate = replicate; diff --git a/resources/databases.ts b/resources/databases.ts index 7acb7d7752..f337e631d3 100644 --- a/resources/databases.ts +++ b/resources/databases.ts @@ -582,6 +582,7 @@ function initStores( const expiration = primaryAttribute.expiration; const eviction = primaryAttribute.eviction; const sealed = primaryAttribute.sealed; + const cacheControl = primaryAttribute.cacheControl; const splitSegments = primaryAttribute.splitSegments; const replicate = primaryAttribute.replicate; if (table && !recreateForEngineChange) { @@ -720,6 +721,7 @@ function initStores( replicate, expirationMS: expiration && expiration * 1000, evictionMS: eviction && eviction * 1000, + cacheControl, trackDeletes, tableName, tableId, @@ -773,6 +775,9 @@ interface TableDefinition { description?: string; properties?: Record; hidden?: boolean; + // default Cache-Control for anonymous REST reads; null = schema explicitly has none (clears a + // prior value on reload), undefined = caller is not schema-defining (leave the current value) + cacheControl?: string | null; } /** * Ensure that we have this database object (that holds a set of tables) set up @@ -1003,6 +1008,7 @@ export function table(tableDefinition: TableDefinition): Tabl description, properties, hidden, + cacheControl, } = tableDefinition; if (!databaseName) databaseName = DEFAULT_DATABASE_NAME; const rootStore = database({ database: databaseName, table: tableName }); @@ -1049,6 +1055,8 @@ export function table(tableDefinition: TableDefinition): Tabl Table.description = description; Table.properties = properties; Table.hidden = hidden; + // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber + if (cacheControl !== undefined) Table.cacheControl = cacheControl; } else { const auditStore = rootStore.auditStore; primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {}; @@ -1062,6 +1070,12 @@ export function table(tableDefinition: TableDefinition): Tabl audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG); if (expiration) primaryKeyAttribute.expiration = expiration; if (eviction) primaryKeyAttribute.eviction = eviction; + // persist cacheControl so all threads (and future boots) see it; undefined callers inherit + // a descriptor value carried by cluster schema events; null (schema has no directive) + // clears a stale value the carried descriptor may hold + if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl; + else if (cacheControl === null) delete primaryKeyAttribute.cacheControl; + else primaryKeyAttribute.cacheControl = cacheControl; splitSegments ??= false; primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed; @@ -1153,6 +1167,7 @@ export function table(tableDefinition: TableDefinition): Tabl description, properties, hidden, + cacheControl, }) ); Table.schemaVersion = 1; diff --git a/resources/graphql.ts b/resources/graphql.ts index 66489fa156..9cd9a573b6 100644 --- a/resources/graphql.ts +++ b/resources/graphql.ts @@ -118,6 +118,14 @@ async function processGraphQLSchema(gqlContent, urlPath, filePath, resources) { // Boolean directive args arrive as actual booleans; tolerate string forms too. if (typeDef.randomAccessFields !== undefined) typeDef.randomAccessFields = typeDef.randomAccessFields === true || typeDef.randomAccessFields === 'true'; + // schema is authoritative for cacheControl: null (vs undefined) clears a removed directive on reload; + // coerce to a header-safe string so a non-string arg or CRLF can't reach the HTTP response + typeDef.cacheControl = + typeDef.cacheControl == null + ? null + : String(typeDef.cacheControl) + .replace(/[\r\n]+/g, ' ') + .trim(); tables.push(typeDef); } if (directive.name.value === 'sealed') typeDef.sealed = true; diff --git a/security/auth.ts b/security/auth.ts index 2789a58437..a6649eabba 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -10,7 +10,7 @@ import harperLogger from '../utility/logging/harper_logger.ts'; const { forComponent, AuthAuditLog } = harperLogger; import serverHandlers from '../server/itc/serverHandlers.js'; const { user } = serverHandlers; -import { Headers } from '../server/serverHelpers/Headers.ts'; +import { Headers, addVaryHeader } from '../server/serverHelpers/Headers.ts'; import { convertToMS } from '../utility/common_utils.ts'; import { verifyCertificate } from './certificateVerification/index.ts'; import { serializeMessage } from '../server/serverHelpers/contentTypes.ts'; @@ -33,16 +33,27 @@ function getSessionTable() { return _sessionTable; } const ENABLE_SESSIONS = env.get(CONFIG_PARAMS.AUTHENTICATION_ENABLESESSIONS) ?? true; +// env-var strings need boolean parsing: a raw 'false'/'0' string is truthy, which would turn +// AUTHENTICATION_AUTHORIZELOCAL=false into an *enabled* auth bypass +function envFlag(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + return normalized !== 'false' && normalized !== '0' && normalized !== ''; +} // check the environment for a flag to bypass authentication (for testing) since it doesn't necessarily get set on child threads let AUTHORIZE_LOCAL = - process.env.AUTHENTICATION_AUTHORIZELOCAL ?? + envFlag(process.env.AUTHENTICATION_AUTHORIZELOCAL) ?? env.get(CONFIG_PARAMS.AUTHENTICATION_AUTHORIZELOCAL) ?? - process.env.DEV_MODE; + envFlag(process.env.DEV_MODE); const LOG_AUTH_SUCCESSFUL = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGSUCCESSFUL) ?? false; const LOG_AUTH_FAILED = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGFAILED) ?? false; const DEFAULT_COOKIE_EXPIRES = 'Tue, 01 Oct 8307 19:33:20 GMT'; +// RFC 9111 cache-scope directives; boundaries on both sides so a token like `public-foo` doesn't match +const SHARED_CACHE_OPTIN = /(^|[,\s])(public|s-maxage)($|[\s,;=])/i; +const PRIVATE_SCOPE = /(^|[,\s])(private|no-store)($|[\s,;=])/i; + let authorizationCache = new Map(); server.onInvalidatedUser(() => { // TODO: Eventually we probably want to be able to invalidate individual users @@ -61,6 +72,9 @@ export async function authentication(request, nextHandler) { const cookie = headers.cookie; let origin = headers.origin; let responseHeaders = []; + // a 401 that gets rewritten to a login-redirect 302 below must still be treated as + // identity-dependent by applyResponseHeaders + let wasUnauthorized = false; try { if (origin) { const accessList = request.isOperationsServer @@ -80,6 +94,8 @@ export async function authentication(request, nextHandler) { ['Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, PATCH, OPTIONS'], ['Access-Control-Allow-Headers', accessControlAllowHeaders], ['Access-Control-Allow-Origin', origin], + // the preflight response is keyed on the request Origin — a shared cache must partition on it + ['Vary', 'Origin'], ]); if (ENABLE_SESSIONS) headers.set('Access-Control-Allow-Credentials', 'true'); return { @@ -329,6 +345,7 @@ export async function authentication(request, nextHandler) { const response = await nextHandler(request); if (!response) return response; if (response.status === 401) { + wasUnauthorized = true; if ( headers['user-agent']?.startsWith('Mozilla') && headers.accept?.startsWith('text/html') && @@ -346,13 +363,50 @@ export async function authentication(request, nextHandler) { } function applyResponseHeaders(response) { const l = responseHeaders.length; - if (l > 0) { + // a response is identity-dependent if a principal was resolved (Authorization/session/mTLS) or + // if we are rejecting the credentials (401, possibly rewritten to a login redirect); such a + // response must never be stored by a shared cache and served to a different principal (#1565) + const rejectedAuth = response?.status === 401 || wasUnauthorized; + const identityDependent = !!request.user || rejectedAuth; + // with CORS enabled the response is origin-dependent — ACAO reflects the request Origin, and + // its absence when no Origin was sent is origin-dependent too — so a shared cache must + // partition on Origin either way (#1518) + const corsEnabled = request.isOperationsServer ? operationsCors : appsCors; + if ((l > 0 || corsEnabled || identityDependent) && typeof response === 'object') { let headers = response.headers; - if (!headers) response.headers = headers = new Headers(); + // normalize plain-object headers so get/set below work uniformly + if (!headers || typeof headers.set !== 'function') response.headers = headers = new Headers(headers); for (let i = 0; i < l;) { const name = responseHeaders[i++]; headers.set(name, responseHeaders[i++]); } + if (corsEnabled) addVaryHeader(headers, 'Origin'); + // #1565: keep identity-dependent responses out of shared caches. An explicit + // `public`/`s-maxage` set by the app is RFC 9111's opt-in for shared-caching an + // authenticated response, so that is trusted as-is — but only for successfully + // authenticated responses; a credential rejection must never opt into shared caching. + // Otherwise partition on the credential headers and ensure a `private` scope + // (`private, no-cache` when none was set, so browsers still revalidate against the + // ETag/Last-Modified we emit). + if (identityDependent) { + const existingCacheControl = headers.get('Cache-Control'); + const cacheControlString = existingCacheControl + ? Array.isArray(existingCacheControl) + ? existingCacheControl.join(', ') + : existingCacheControl + : ''; + const optedIn = !rejectedAuth && SHARED_CACHE_OPTIN.test(cacheControlString); + if (!optedIn) { + addVaryHeader(headers, 'Authorization'); + if (ENABLE_SESSIONS) addVaryHeader(headers, 'Cookie'); + // a 401 never opts into shared caching: any non-private Cache-Control it carries is + // replaced outright (an existing private/no-store is already at least as strict) + if (!cacheControlString || (rejectedAuth && !PRIVATE_SCOPE.test(cacheControlString))) + headers.set('Cache-Control', 'private, no-cache'); + else if (!PRIVATE_SCOPE.test(cacheControlString)) + headers.set('Cache-Control', cacheControlString + ', private'); + } + } } responseHeaders = null; return response; diff --git a/server/DESIGN.md b/server/DESIGN.md index 9449fd2fd0..1bc70b1fa5 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -120,6 +120,16 @@ The default WebSocket upgrade handler is registered automatically inside `onWebS `REST.ts → http(request, nextHandler)` is the chief integration point: it takes a `Request`, asks the `Resources` registry for a match, builds a `RequestTarget`, and dispatches into the Resource class's static method. Cache headers are translated to `request.expiresAt` / `onlyIfCached` / `noCache` flags within the same function. +### Response Cache-Control / Vary policy (#1518, #1565) + +Three tiers, applied in two places: + +1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. The declaration is required: anonymous readability alone never emits shared-cache headers, because a request-attribute-gated `allowRead` (IP, headers) would make inferred `public` unsound. +2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), _unless_ the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). +3. **CORS partitioning** — when CORS is enabled, every response gets `Vary: Origin` (the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too). + +The `@table(cacheControl:)` value is persisted on the primary-key attribute (like `expiration`), so all threads and future boots see it; `resources/databases.ts → table()` treats `null` as "schema explicitly has none" (clears on reload) and `undefined` as "caller is not schema-defining" (no clobber from `add_attribute`/cluster schema events). + --- ## "Where is X" cheat sheet diff --git a/server/REST.ts b/server/REST.ts index c53dcb0861..0913b897c7 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -254,6 +254,20 @@ async function http(request: Request, nextHandler) { headers, body: undefined, }; + // #1565 converse: a declared `@table(cacheControl: "...")`/static cacheControl is emitted on + // anonymous reads so a shared cache/CDN can store public responses. Emission requires the + // explicit declaration — "the anonymous request succeeded" alone doesn't establish that the + // content is uniformly public (an allowRead gated on IP or headers would leak across a + // URL-keyed cache). Authenticated responses are excluded here — the auth layer applies a + // `private` floor to them instead. + if (!request.user && (method === 'GET' || method === 'HEAD')) { + const responseStatus = responseObject.status; + if (responseStatus === 200 || responseStatus === 304) { + const cacheControl = (resource as any)?.cacheControl; + // setIfNone: a resource-set Cache-Control (including a full no-store opt-out) always wins + if (cacheControl) headers.setIfNone('Cache-Control', cacheControl); + } + } const loadedFromSource = target.loadedFromSource; if (loadedFromSource !== undefined) { // this appears to be a caching table with a source diff --git a/server/serverHelpers/Headers.ts b/server/serverHelpers/Headers.ts index bf34d9c2c6..a7ee3a20d5 100644 --- a/server/serverHelpers/Headers.ts +++ b/server/serverHelpers/Headers.ts @@ -60,6 +60,30 @@ export class Headers extends Map { } } +/** + * Add a field-name token to the `Vary` response header, appending to (and de-duplicating against) any + * existing value rather than overwriting it. Used to declare cache-partitioning dimensions (Origin, + * Authorization, Cookie) so a shared cache/CDN keys the response correctly (#1518, #1565). + */ +export function addVaryHeader( + headers: { get(name: string): any; set(name: string, value: string): any }, + token: string +) { + const existing = headers.get('Vary'); + if (!existing) { + headers.set('Vary', token); + return; + } + const existingString = Array.isArray(existing) ? existing.join(', ') : existing; + const lowerToken = token.toLowerCase(); + for (const part of existingString.split(',')) { + const trimmed = part.trim().toLowerCase(); + // a `Vary: *` already covers every dimension, and an exact token match is a no-op + if (trimmed === lowerToken || trimmed === '*') return; + } + headers.set('Vary', existingString + ', ' + token); +} + export function appendHeader(headers, name, value, commaDelimited) { if (headers.append) { headers.append(name, value, commaDelimited); diff --git a/unitTests/security/auth.test.js b/unitTests/security/auth.test.js index dd4db4ed84..684ab05ce1 100644 --- a/unitTests/security/auth.test.js +++ b/unitTests/security/auth.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert'); const sinon = require('sinon'); +const { Headers } = require('#src/server/serverHelpers/Headers'); // First set up test environment const testUtils = require('../testUtils.js'); @@ -87,9 +88,7 @@ describe('auth.ts - certificate verification integration', function () { response = { status: 200, - headers: { - set: sandbox.stub(), - }, + headers: new Headers(), body: {}, }; @@ -346,9 +345,7 @@ describe('auth.ts - certificate verification integration', function () { nextHandler = sandbox.stub().resolves({ status: 200, - headers: { - set: sandbox.stub(), - }, + headers: new Headers(), }); }); diff --git a/unitTests/server/serverHelpers/Headers.test.js b/unitTests/server/serverHelpers/Headers.test.js index 53ad819ca5..72f4929e95 100644 --- a/unitTests/server/serverHelpers/Headers.test.js +++ b/unitTests/server/serverHelpers/Headers.test.js @@ -1,7 +1,13 @@ 'use strict'; const assert = require('assert'); -const { Headers, appendHeader, mergeHeaders, toWriteHeadHeaders } = require('#src/server/serverHelpers/Headers'); +const { + Headers, + appendHeader, + mergeHeaders, + toWriteHeadHeaders, + addVaryHeader, +} = require('#src/server/serverHelpers/Headers'); describe('Test Headers', () => { describe(`Create and modify headers`, function () { it('should handle headers', async function () { @@ -440,3 +446,36 @@ describe('toWriteHeadHeaders', () => { } }); }); + +describe('addVaryHeader', () => { + it('sets Vary when absent', () => { + const headers = new Headers(); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Origin'); + }); + it('appends to an existing Vary value', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept, Accept-Encoding'); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Accept, Accept-Encoding, Origin'); + }); + it('deduplicates case-insensitively', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept, origin'); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Accept, origin'); + }); + it('is a no-op when Vary is *', () => { + const headers = new Headers(); + headers.set('Vary', '*'); + addVaryHeader(headers, 'Authorization'); + assert.equal(headers.get('Vary'), '*'); + }); + it('handles an array-valued Vary', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept'); + headers.append('Vary', 'Accept-Encoding'); + addVaryHeader(headers, 'Cookie'); + assert.equal(headers.get('Vary'), 'Accept, Accept-Encoding, Cookie'); + }); +});