Skip to content

Cache-Control/Vary hardening and shared-cache defaults#1746

Merged
kriszyp merged 5 commits into
mainfrom
kris/cache-headers-1565-1518
Jul 10, 2026
Merged

Cache-Control/Vary hardening and shared-cache defaults#1746
kriszyp merged 5 commits into
mainfrom
kris/cache-headers-1565-1518

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 9, 2026

Copy link
Copy Markdown
Member

Fixes #1518 and #1565, plus the converse: declared caching headers so shared caches/CDNs (e.g. Akamai) can safely cache Harper responses.

Summary

#1518Vary: Origin. CORS-enabled responses (default config reflects any Origin into ACAO) now emit Vary: Origin — on reflected responses, on preflights, and on no-Origin responses (whose ACAO-less variant is also origin-dependent). Appended to the existing Vary, not clobbering it.

#1565 — identity floor. In security/auth.ts → applyResponseHeaders (the choke point all authenticated surfaces pass through): any response where a principal was resolved, or credentials were rejected (401, including the 401→302 login redirect), gets Cache-Control: private, no-cache + Vary: Authorization (+ Cookie with sessions). Per RFC 9111, an app-set public/s-maxage is the explicit opt-in to shared-caching an authenticated response and is trusted — but never on a 401 (a rejection's non-private Cache-Control is replaced outright). An app Cache-Control without a scope directive (e.g. bare max-age=60) gets , private appended (matters for the cookie-auth path, which shared caches would otherwise store).

Converse — declared shared-cache headers for anonymous reads. New @table(cacheControl: "public, max-age=60") directive (also a static cacheControl on JS resources), emitted on anonymous GET/HEAD 200/304 responses only. The declaration is required: anonymous readability alone never emits shared-cache headers, because "the anonymous request succeeded" doesn't establish the content is uniformly public — an allowRead gated on request attributes (IP, headers) would leak across a URL-keyed cache. (An earlier revision auto-derived public, s-maxage=<expiration> for caching tables; dropped for this reason.) The value is persisted on the primary-key attribute (like expiration) so all threads and future boots see it, and surfaces in describe_table. Authenticated reads never inherit the table declaration — the identity floor covers them; a resource that wants public-for-authenticated sets the header in code.

Two root-cause fixes found along the way (both pre-existing bugs):

  • initSystemSchemaPaths (lmdbBridge) did Object.assign(process.env, minimist(process.argv)) — permanently clobbering real env vars with CLI args (this is why AUTHENTICATION_AUTHORIZELOCAL=false in the environment could not beat a --AUTHENTICATION_AUTHORIZELOCAL=true CLI arg). Now a non-mutating merge.
  • AUTHENTICATION_AUTHORIZELOCAL/DEV_MODE env strings were used raw, so the string 'false' enabled the local auth bypass. Now parsed ('false'/'0'/'' → false, case/whitespace-insensitive).

Where to look

  • security/auth.ts — the floor logic and the 401-never-opts-in rule; also the wasUnauthorized capture so the login-redirect 302 keeps the floor.
  • resources/databases.tscacheControl persistence semantics: null = schema explicitly has none (clears on reload), undefined = non-schema caller (add_attribute, cluster schema events — no clobber). This mirrors but slightly diverges from how description/hidden are handled (those clobber; pre-existing).
  • server/REST.ts — anonymous-only emission, gated to 200/304.

Deliberate choices a reviewer may want to weigh

  • Responses returned as Response-like envelopes (e.g. from a source) exit through finalizeResponse() before the anonymous-emission block, so the table-level declaration doesn't apply there — the source's own headers win. Believed correct; flagging as reviewed-and-accepted.
  • no-store was considered and rejected for the floor: private, no-cache keeps the browser cache + ETag revalidation (Harper already emits ETags) while blocking shared caches.
  • Static-file caching headers (the static plugin currently hardcodes send's public, max-age=0) are a separate follow-up PR: configurable maxAge/immutable/cacheControl passthrough.

Cross-model review (Codex + Gemini + domain pass) ran; all blockers/significant findings were fixed in this diff (401 opt-in bypass, login-redirect escape, regex boundaries, env parsing, opt-out persistence). No open findings.

Tests: 14-case integration suite (integrationTests/server/http-cache-headers.test.ts) covering all three tiers + unit tests for addVaryHeader. Existing auth/server/config unit suites pass.

Generated by Claude (Opus 4.8) with Kris.

- Add Vary: Origin to CORS-enabled responses and preflight (#1518)
- Stamp identity-dependent responses (authenticated or 401) with
  Cache-Control: private, no-cache + Vary: Authorization/Cookie, unless
  the app opted into shared caching with public/s-maxage (#1565)
- New @table(cacheControl: "...") directive / resource static emits a
  default Cache-Control on anonymous reads; caching tables fall back to
  public, s-maxage=<expiration> (#1565 converse); persisted on the
  primary-key attribute like expiration
- Fix initSystemSchemaPaths mutating process.env with CLI args (this
  clobbered real env vars, e.g. AUTHENTICATION_AUTHORIZELOCAL)
- Parse boolean-ish env strings for AUTHENTICATION_AUTHORIZELOCAL so
  'false' no longer enables the local auth bypass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp kriszyp requested review from Ethan-Arrowood and heskew July 9, 2026 21:42
gemini-code-assist[bot]

This comment was marked as resolved.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Comment thread server/serverHelpers/Headers.ts Outdated
Comment thread security/auth.ts Outdated
Comment thread server/REST.ts
Kris Zyp and others added 4 commits July 9, 2026 16:30
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oor, type addVaryHeader

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… s-maxage fallback

Anonymous readability alone doesn't establish that content is uniformly
public (an allowRead gated on IP or headers would leak across a URL-keyed
shared cache), so shared-cache headers now require the explicit
@table(cacheControl:) / static cacheControl declaration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Marking ready with "Integration Tests 2/6 (Windows)" red: that failure is the @embed-directive suite wholesale timeout, a pre-existing Windows flake on main — same suite/signature red on main runs 29015958195 and 28981465236 (and shard 3/6 on 29050105369). Everything else is green, including the cache-header integration suite this PR adds. — Claude (Opus 4.8)

@kriszyp

kriszyp commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Companion docs PR: HarperFast/documentation#578 (covers the @table(cacheControl:) directive, the identity-floor/Vary behavior, and the static plugin options from #1748). — Claude (Opus 4.8)

@Ethan-Arrowood Ethan-Arrowood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the 401-never-opts-in rule and the env-flag parsing fix are both great catches. The Windows red is the known @embed flake (same signature on main).

sent with Claude Fable 5

@kriszyp kriszyp merged commit 5d87b37 into main Jul 10, 2026
116 of 118 checks passed
@kriszyp kriszyp deleted the kris/cache-headers-1565-1518 branch July 10, 2026 22:08
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.

CORS: responses omit Vary: Origin — cache-poisoning seam behind a shared cache/CDN

2 participants