Skip to content

fix(cache): private routes served from a public cache + bypass hardening - #512

Merged
JonasJesus42 merged 3 commits into
mainfrom
fix/cache-private-routes-hardening
Sep 1, 2026
Merged

fix(cache): private routes served from a public cache + bypass hardening#512
JonasJesus42 merged 3 commits into
mainfrom
fix/cache-private-routes-hardening

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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 the
moment something does (Workers Cache, a CDN rule).

1. Private routes falling into a public cache

PRIVATE_PREFIX_RE covered a short list, was case-sensitive, and was anchored at the root. Anything that
didn't match fell through to the listing default — 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_SEGMENTS list, case-insensitive, tolerating a locale prefix.

2. hasOnlySafeCookies was fail-open

A response that has a set-cookie whose 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. bypassPaths replaced the defaults

A site passing bypassPaths to add a path silently lost /deco/, /live/ and /.decofile. Now always
merged.

4. CDN-Cache-Control decided in one place

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
returning 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 to no-store, and a value the
cache 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:

registerPrivatePaths(["/listadedesejos", "/trocas"]);   // new — can only restrict
  • registerCachePattern still wins over the built-ins, except that it can no longer make a private path
    public — a broad site pattern was capturing /checkout.
  • setCacheProfile("private", { isPublic: true }) is refused with a warning. allowPublicPrivateProfile() is
    the escape hatch, and it has a name you have to type.

6. The VTEX middleware overwrote Cache-Control on every response

It 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=900 to vtexCacheControl's generic
s-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-Control too — otherwise a response goes out as private, no-store alongside
public, max-age=300, and Cloudflare gives the latter precedence.

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 entry.

Validation

2553 tests passing. Typecheck clean across the three packages. Also verified against a real built site's
worker: /listadedesejos, /Checkout and /pt/minha-conta now come back private, no-store; they were
public listing before.

The 4 failures in draft preview (workerEntry.test.ts, nextjs/draftShell.test.ts) are pre-existing on
main
— confirmed with git stash.

🤖 Generated with Claude Code

JonasJesus42 and others added 3 commits August 27, 2026 20:14
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>
@JonasJesus42
JonasJesus42 requested a review from a team August 27, 2026 23:19
@JonasJesus42 JonasJesus42 changed the title fix(cache): rotas privadas em cache público + endurecimento de bypass fix(cache): private routes served from a public cache + bypass hardening Aug 28, 2026
@JonasJesus42
JonasJesus42 merged commit c19a3c6 into main Sep 1, 2026
1 check passed
@JonasJesus42
JonasJesus42 deleted the fix/cache-private-routes-hardening branch September 1, 2026 13:56
@JonasJesus42
JonasJesus42 restored the fix/cache-private-routes-hardening branch September 1, 2026 13:57
@JonasJesus42
JonasJesus42 deleted the fix/cache-private-routes-hardening branch September 1, 2026 13:57
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 7.56.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant