fix(cache): private routes served from a public cache + bypass hardening - #512
Merged
Conversation
Every response currently ships `CDN-Cache-Control: no-store`, which hides a few real gaps in how routes are classified. They are harmless only while nothing is cached at the CDN; enabling that turns each one into a leak. - `PRIVATE_PREFIX_RE` only matched a short list, was case-sensitive and anchored at the root, so `/listadedesejos`, `/wishlist`, `/favoritos`, `/orders`, `/profile`, `/logout`, `/cadastro` and returns routes all fell through to the cacheable `listing` default (public, 120s edge) — as did `/Checkout` and `/pt/checkout`. Rebuilt from a `PRIVATE_SEGMENTS` list, case-insensitive, tolerating a locale prefix. - `setCacheProfile` would happily flip `private`/`cart`/`none` to public via a props bag. Now refused with a warning unless `allowPublicPrivateProfile()` is called first — the escape hatch has a name you have to type. - `registerCachePattern` is evaluated before the built-ins "so they can override defaults", which let a broad site pattern capture `/checkout` and make it public. Custom patterns can still tighten anything; they can no longer make a private path public. - Adds `registerPrivatePaths()`, the safe half of cache configuration: it can only restrict, and (because the Worker is the source of truth for cacheability) it propagates to the CDN with no rule change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…response The VTEX app middleware wraps `handleRequest`, so it runs after the entire edge-cache layer and is the last writer of `Cache-Control` — including on a cache HIT. It overwrote unconditionally, which downgraded a home page the cache layer had resolved as `s-maxage=900` to `vtexCacheControl`'s generic `s-maxage=60`, throwing away the per-profile TTL. It now only speaks up for the case it actually knows better about — a logged-in or custom-pricing request — and when it does, it clears `CDN-Cache-Control` too. Otherwise a response could go out as `Cache-Control: private, no-store` alongside `CDN-Cache-Control: public, max-age=300`, and Cloudflare gives the CDN header precedence. Same pairing the Worker's own bypasses and `utils/proxy.ts` already use. Exports `vtexMiddleware` so the behaviour is testable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps that are currently masked by every response shipping `CDN-Cache-Control: no-store`. They are harmless only while nothing is cached in front of the Worker — the moment anything is (Workers Cache, a CDN rule), each one becomes a cross-user leak. - `hasOnlySafeCookies` was fail-open: a response that HAS a `set-cookie` whose names failed to parse was treated as safe, i.e. cacheable. The parser's fallback path is documented as unreliable, and the two outcomes are not symmetric — guessing "safe" caches a personalized response into the shared entry. Now fail-closed. - `bypassPaths` REPLACED the framework defaults instead of extending them, so a site adding one path silently lost `/deco/`, `/live/` and `/.decofile`. Now always merged. - `CDN-Cache-Control` is decided at the single response exit. Previously a dozen bypass call sites each decided for themselves: some deleted the header, some didn't, and several still emitted the profile's public `Cache-Control` (`public, s-maxage=900`) on the way out. Branches that return before the cache layer (`?asJson`, `?renderJson`, proxy, redirects) emitted nothing at all. Now: bypass forces `no-store`, an absent header defaults to `no-store`, and a value the cache layer already decided is left alone — so an early return can only ever be more restrictive, never accidentally public. Also warns at boot when `buildSegment` is missing, since the logged-in bypass reads `segment.loggedIn` and is inert without it — authenticated and anonymous visitors then share one edge entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 27, 2026
|
🎉 This PR is included in version 7.56.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.
Split out of #510 at review request — none of this depends on anything else, it stands on its own, and it can
land without waiting on the CDN feature review.
These are all bugs already in production today. They were masked by every response shipping
CDN-Cache-Control: no-store: harmless while nothing caches in front of the Worker, and a cross-user leak themoment something does (Workers Cache, a CDN rule).
1. Private routes falling into a public cache
PRIVATE_PREFIX_REcovered a short list, was case-sensitive, and was anchored at the root. Anything thatdidn't match fell through to the
listingdefault — public, 120s edge:/listadedesejos,/wishlist,/favoritos,/orders,/order-placed,/profile,/perfil,/logout,/sair,/cadastro,/signup,/register,/assinaturas,/troca,/devolucao— and also/Checkout(capitalized) and
/pt/checkout(locale prefix).Rebuilt from a
PRIVATE_SEGMENTSlist, case-insensitive, tolerating a locale prefix.2.
hasOnlySafeCookieswas fail-openA response that has a
set-cookiewhose names the parser failed to extract was treated as safe, i.e.cacheable. The parser's fallback path is documented in the code itself as unreliable, and the two outcomes are
not symmetric: guessing "safe" caches a personalized response into the shared entry. Now fails closed.
3.
bypassPathsreplaced the defaultsA site passing
bypassPathsto add a path silently lost/deco/,/live/and/.decofile. Now alwaysmerged.
4.
CDN-Cache-Controldecided in one placePreviously a dozen bypass call sites each decided for themselves: some deleted the header, some didn't, and
several still emitted the profile's public
Cache-Control(public, s-maxage=900) on the way out. Branchesreturning before the cache layer (
?asJson,?renderJson, proxy, redirects) emitted nothing at all.Now, at the single exit: a bypass forces
no-store, an absent header defaults tono-store, and a value thecache layer already decided is left alone. An early return can only ever be more restrictive, never
accidentally public.
5. Site configuration: free to tighten, noisy to loosen
Adding a restriction is always safe; removing one is what leaks user data. So:
registerCachePatternstill wins over the built-ins, except that it can no longer make a private pathpublic — a broad site pattern was capturing
/checkout.setCacheProfile("private", { isPublic: true })is refused with a warning.allowPublicPrivateProfile()isthe escape hatch, and it has a name you have to type.
6. The VTEX middleware overwrote
Cache-Controlon every responseIt wraps
handleRequest, so it runs after the whole cache layer and is the last writer of the header —including on a HIT. It downgraded a home page from
s-maxage=900tovtexCacheControl's generics-maxage=60.It now only speaks up for the case it genuinely knows better about (logged-in or custom pricing) and, when it
does, clears
CDN-Cache-Controltoo — otherwise a response goes out asprivate, no-storealongsidepublic, max-age=300, and Cloudflare gives the latter precedence.Also
Warns at boot when
buildSegmentis missing, since the logged-in bypass readssegment.loggedInand is inertwithout it — authenticated and anonymous visitors then share one entry.
Validation
2553tests passing. Typecheck clean across the three packages. Also verified against a real built site'sworker:
/listadedesejos,/Checkoutand/pt/minha-contanow come backprivate, no-store; they werepublic
listingbefore.🤖 Generated with Claude Code