Skip to content

fix(cache): rotas privadas em cache público + endurecimento de bypass - #512

Open
JonasJesus42 wants to merge 3 commits into
mainfrom
fix/cache-private-routes-hardening
Open

fix(cache): rotas privadas em cache público + endurecimento de bypass#512
JonasJesus42 wants to merge 3 commits into
mainfrom
fix/cache-private-routes-hardening

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Separado do #510 a pedido do review — estas correções não dependem de nada, valem sozinhas e podem mergear
sem esperar o review da feature de CDN.

Todas são bugs que já estão em produção hoje. Estavam mascarados pelo fato de toda resposta sair com
CDN-Cache-Control: no-store: são inofensivos enquanto não existe nada cacheando na frente do Worker, e viram
vazamento entre usuários no momento em que existir (Workers Cache, regra de CDN).

1. Rotas privadas caindo em cache público

PRIVATE_PREFIX_RE cobria uma lista curta, era case-sensitive e ancorada na raiz. Tudo que não batia caía no
default listingpúblico, 120s de edge:

/listadedesejos, /wishlist, /favoritos, /orders, /order-placed, /profile, /perfil, /logout,
/sair, /cadastro, /signup, /register, /assinaturas, /troca, /devolucao — e também /Checkout
(maiúscula) e /pt/checkout (prefixo de locale).

Reconstruída a partir de uma lista PRIVATE_SEGMENTS, case-insensitive, tolerando prefixo de locale.

2. hasOnlySafeCookies era fail-open

Uma resposta que tem set-cookie mas cujos nomes o parser não conseguiu extrair era tratada como segura,
isto é, cacheável. O caminho de fallback do parser é documentado no próprio código como pouco confiável, e os
dois desfechos não são simétricos: chutar "seguro" cacheia uma resposta personalizada na entrada compartilhada.
Agora falha fechado.

3. bypassPaths substituía os defaults

Um site que passava bypassPaths para acrescentar um path perdia silenciosamente /deco/, /live/ e
/.decofile. Agora sempre soma.

4. CDN-Cache-Control decidido num ponto só

Antes, uma dúzia de call sites de bypass decidia cada um por si: alguns deletavam o header, outros não, e
vários ainda emitiam o Cache-Control público do perfil (public, s-maxage=900) na saída. Branches que
retornam antes da camada de cache (?asJson, ?renderJson, proxy, redirects) não emitiam nada.

Agora, no exit único: bypass força no-store, header ausente vira no-store, e um valor que a camada de
cache já decidiu é preservado. Um early return só pode ser mais restritivo, nunca acidentalmente público.

5. Configuração pelo site: livre para apertar, ruidosa para afrouxar

Adicionar restrição é sempre seguro; remover é o que vaza dado. Então:

registerPrivatePaths(["/listadedesejos", "/trocas"]);   // novo, só restringe
  • registerCachePattern continua vencendo os builtins, exceto que não consegue mais tornar pública uma
    rota privada — um pattern amplo do site capturava /checkout.
  • setCacheProfile("private", { isPublic: true }) é recusado com aviso. allowPublicPrivateProfile() é a
    saída, e tem um nome que você precisa digitar.

6. Middleware VTEX sobrescrevia Cache-Control em toda resposta

Ele envolve handleRequest, então roda depois de toda a camada de cache e é o último a escrever o header —
inclusive em HIT. Rebaixava uma home de s-maxage=900 para o s-maxage=60 genérico de vtexCacheControl.

Agora só age no caso que ele realmente conhece melhor (logado ou custom pricing) e, quando age, limpa também
o CDN-Cache-Control — senão sai private, no-store ao lado de public, max-age=300, e a Cloudflare dá
precedência ao segundo.

Além disso

Avisa no boot quando falta buildSegment, já que o bypass de logado lê segment.loggedIn e é inerte sem ele
— usuário autenticado e anônimo compartilham a mesma entrada.

Validação

2553 testes passando. Typecheck limpo nos três pacotes. Conferido também no worker real de um site buildado:
/listadedesejos, /Checkout e /pt/minha-conta saem private, no-store; antes eram listing público.

As 4 falhas em draft preview (workerEntry.test.ts, nextjs/draftShell.test.ts) são pré-existentes no
main
— confirmado com git stash.

🤖 Generated with Claude Code


Summary by cubic

Closes cache-classification bugs that are latent today — every response ships CDN-Cache-Control: no-store — but would leak authenticated pages between users once CDN caching is enabled.

Bug Fixes

  • Private-route matching is now case-insensitive, tolerates a locale prefix, and covers wishlist, favorites, orders, profile, logout, signup and returns paths that fell through to public listing.
  • hasOnlySafeCookies now fails closed when a set-cookie header can't be parsed into names.
  • bypassPaths now always merges with /deco/, /live/ and /.decofile instead of replacing them.
  • CDN-Cache-Control is decided at one exit point: bypasses and early returns (?asJson, redirects, proxy) force no-store, and a value the cache layer set is preserved.
  • The VTEX middleware no longer rewrites every response to its generic s-maxage=60; it only does for logged-in or custom-pricing requests, and clears CDN-Cache-Control then.
  • Boot warns when buildSegment is missing, since the logged-in bypass reads segment.loggedIn and is inert without it.

Migration

  • setCacheProfile("private", { isPublic: true }) is now refused with a warning; call allowPublicPrivateProfile() if a site genuinely needs it.
  • Custom cache patterns can no longer make a private path public; use the new registerPrivatePaths() (only restricts) to add site-specific private routes.

Written for commit a03f048. Summary will update on new commits.

Review in cubic

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

1 participant