feat: keep documents private by default - #162
Merged
Merged
Conversation
A document carries the hydrated data of every loader that ran and the
request's serialized context, but a loader's, action's or thrown error's
cache headers reached it as written. `Cache-Control: public` from one route
made a publicly cacheable page holding per-reader data.
On a document, Juniper now rewrites route cache headers so a shared cache
cannot store the page: `Cache-Control` drops `public`, `s-maxage` and a
field-qualified `private` and gains `private` unless `private` or
`no-store` remains; CDN cache fields become `no-store`; a lone `Expires` is
dropped. Route middleware opts a page back in with
`c.set("publicDocument", true)`. Data responses and middleware-set headers
are unchanged. The document now commits through `newResponse`, the same
path as every other loader and action response.
Closes #157
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🎉 This PR is included in version 0.17.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A document (HTML) response carries the hydrated data of every loader that ran, such as a layout loader's signed-in user, and the request's serialized context. Before this change, the deepest route's loader or action headers reached the document as written. Since 0.16.4, so did a thrown
HttpError's headers. So a loader or error sendingCache-Control: public, max-age=60made a publicly cacheable page that held one reader's data. udibo found this class of leak in practice and fixed it on its side (udibo/udibo#1482). Data responses have been private by default since #145. This PR does the same for documents.The rule. On a document, Juniper keeps the cache headers that come from the route from letting a shared cache store the page. That covers a loader's or action's
data()orResponse, and anHttpErrorthrown by a loader, action or middleware.Cache-Control:public,s-maxage, and a field-qualifiedprivate="…"are removed.privateis added at the front unless a bareprivateorno-storeremains. Every other directive is kept, includingmax-age,no-cache,no-storeandstale-while-revalidate. Sopublic, max-age=60becomesprivate, max-age=60,public, no-storebecomesno-store, andmax-age=60becomesprivate, max-age=60. A policy that needs no change is sent byte for byte.CDN-Cache-Control,*-CDN-Cache-ControlandSurrogate-Controlbecomeno-store. Dropping them instead could let a CDN fall back to its default TTL, andprivateisn't defined forSurrogate-Control.Expiresis dropped when the route sends noCache-Control, because on its own it makes the page storable by a shared cache.No exemption for documents without loader data. The rule applies even when no loader ran, for example on the error page for an
HttpErrorthat middleware throws. Such a document still serializes the request's router context, which middleware usually fills per user, and its layouts can render from that context. Juniper can't tell per-user context from shared context, so "no loader data" doesn't mean "nothing per-request". A page that really is public uses the opt-in.The opt-in. Route middleware sets a new optional
AppEnvvariable,c.set("publicDocument", true), and the route's cache headers go out on the document as written. It is per request and scoped by where the middleware is mounted. It follows the pattern of the existingsecureHeadersNoncevariable.createServertakes no options and is generated intomain.ts. A route-module export would need Builder changes and per-match resolution. So a context variable is the smallest idiomatic surface.Unchanged:
next(), is not rewritten. It is the app's choice for every response of the route. A policy from the route still replaces it, as before.Mechanism.
renderDocumentnow builds the route headers (action, then loader, then error, with cookies appended in that order) into oneHeaders, applies the rule, and returns throughnewResponse→commitResponse, the #152 commit path. Before, it wrote each header withc.headerand returnedstream(c, …)directly. The document is the error page and the normal page alike, so this is the only place the rule runs.Versioning:
feat:, notfix:and notfeat!publicDocumentvariable onAppEnv. That makes it at least afeat.feat:(0.16.0) with a behaviour-change note.featandfeat!both cut a minor. The practical result is 0.17.0, which^0.16ranges don't pick up automatically, so affected apps get the change only when they deliberately upgrade. Afix:would cut 0.16.7, which would reach every^0.16.xapp silently.Behavior change for existing apps
An app that relied on a shared cache storing a document now gets private caching on that document. This affects apps whose loader, action or thrown error sends
public,s-maxage, a baremax-age, a CDN cache field, orExpiresalone on a document route. They opt back in withc.set("publicDocument", true)in that route's middleware, after checking that every loader on the page, layouts included, and the shared context are the same for every visitor. Apps that set their document policy in middleware see no change.Changes
src/_server.tsx:AppEnv.publicDocument?: boolean, with JSDoc.renderDocumentassembles the route headers, then callskeepDocumentPrivate, then commits throughnewResponse.privateCachePolicy, which splits directives quote-aware soprivate="a, b"stays one directive;isSharedCacheDirective;keepDocumentPrivate.src/server.tsx: thecreateServerJSDoc states the document rule and the opt-in.docs/routing.md, Caching Loader Data:Expiresrules, the no-exemption rule, what isn't rewritten, and the opt-in with an example.publiconly on data requests. Before, it also made documents public, which would leak per-user layout data.Response/data()bullets now say what happens on a document.docs/error-handling.md, Error Headers: the error document's cache headers are made private, the data error response keeps them, and a link to the opt-in.src/server.test.tsx: new suite,the cache policy of a document.Testing
Every case in the new suite runs twice: with
cors()in front of the app's middleware and without it. That is 138 cases in all. Each document case first checks the status,Content-Type: text/html; charset=utf-8, and the app's own header. Where the page hydrates loader data, it also checks that the HTML contains the root loader's per-reader state.HttpErrorthe loader throws,data()the loader returns, aResponsethe loader returns,data()the action returns, and anHttpErrorthe action throws. The policies includepublic, max-age=60,s-maxage, casing, qualifiedprivatewith a comma inside the quotes, baremax-age,no-cache,public, no-storeandno-store, and already-private values kept byte for byte.public, max-age=60is kept for every source, for a middleware-thrown error, for CDN fields and forExpires.publicerror is private both when the layout has a loader that didn't run and when no route has a loader.data()'spublicpolicy and its CDN fields.Expires. CDN fields becomeno-store.Expiresis dropped when there is noCache-Controland kept beside one.Before the fix, all 74 rewrite and no-loader-data cases failed at the
Cache-Controlassertion (for example,- public, max-age=60 / + private, max-age=60). The opt-in and unchanged cases passed. The two CDN andExpirescases added in review also failed at their own assertions before that fix.Mutation checks. Each file was restored by writing back the saved content, and a sha256 check confirmed the match. Each mutation failed only at its intended assertion:
Cache-ControlrewritepublicDocumentprivate, or split directives on raw commasprivatecases redno-storeas privateno-storeandpublic, no-storecases redExpiresc.newResponsewithoutcommitResponsecors()onlyGates, from the Juniper root:
deno task check: exit 0deno task test --parallel --reporter=dot: 59 passed, 840 stepsdeno task test:example --parallel --reporter=dot: 33 passedReview. An adversarial reviewer ran 15 probes against this change and against main. It found no regressions from the
renderDocumentrefactor across HEAD, cookie order, status,cors(), a response middleware committed beforenext(), the error handler, deferred documents, bot requests or aborts. It found two defects, both fixed here:CDN-Cache-Control,Surrogate-Controland a loneExpiresfrom a loader still made the document storable by a CDN.Its doc suggestions are applied too: the blog example, the middleware wording, and the
Response/data()bullets. A second pass on those fixes found no defects, and its own two mutations (a narrower CDN field pattern, and always droppingExpires) each failed the intended test.Not covered: vendor cache headers with their own syntax or precedence, such as Akamai's
Edge-Controland nginx'sX-Accel-Expires, pass through unchanged. The CDN rule covers only the fields with a published spec: RFC 9213'sCDN-Cache-Controlfamily andSurrogate-Control. The second reviewer pass raised this as a question of scope, not a defect.Pre-existing, not changed here: under
--parallel,src/utils/testing.test.tsxclears the process environment for a moment. One of #159's cases then sees development mode and fails. I saw it once in four full runs on this branch. It is filed as #161 and left open by this PR.Closes
Closes #157
🤖 Generated with Claude Code