feat(cache): cache de CDN para /_serverFn + correções de rota privada e bypass - #510
feat(cache): cache de CDN para /_serverFn + correções de rota privada e bypass#510JonasJesus42 wants to merge 5 commits into
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>
…ypasses ## Why /_serverFn couldn't be CDN-cached The Worker keys its edge cache on a synthetic Request carrying `__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`), while Cloudflare's CDN keys on the raw URL and ignores `Vary` beyond `Accept-Encoding`. Hence the blanket `CDN-Cache-Control: no-store`, and hence 100% of traffic returning `cf-cache-status: BYPASS`. The fix inverts the problem: the client puts the segment in the URL (`?__cseg=<device>.<buildHash>`, via `decoServerFnFetch`), which makes the CDN's key equivalent to the Worker's. Opt in with `cdnCacheControl: "serverfn-segment"`; the default is unchanged. Fail-closed on every axis — absent, diverging, forged or stale-build marker, logged-in / region / sales-channel, an unknown custom segment field, a bot UA or an A/B cohort cookie all keep today's `no-store`. Worst case is not caching, never a wrong response. `isBot` and the A/B cookie are checked against the same helpers `buildCacheKey` uses, so keying and releasing cannot drift. HTML documents stay `no-store`: the initial navigation is a browser request with no hook to attach a marker. Ported from a working site-level implementation, with two gaps closed — that version verified only the device, leaving bots and A/B cohorts able to poison a shared CDN entry. ## Hardening (independent of the opt-in) - Every bypass now emits `CDN-Cache-Control: no-store`, enforced at the single response exit rather than at twelve call sites. 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. - `hasOnlySafeCookies` was fail-open: a response WITH a `set-cookie` whose names failed to parse was treated as safe, i.e. cacheable. Now fail-closed. - `bypassPaths` replaced the framework defaults instead of extending them, so a site adding one path silently lost `/deco/`, `/live/` and `/.decofile`. - Warns at boot when `buildSegment` is missing, since the logged-in bypass is inert without it and authenticated visitors share the anonymous entry. - `cdnCacheControl: "match-profile"` promised safety "without buildSegment and deviceSpecificKeys: false", but `deviceSpecificKeys` defaults to true, so the condition is false on every site. It is now refused with a warning instead of silently cross-serving segments. 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>
…tants Caching HTML at the CDN is not a header change: the zone has to reproduce the parts of the cache key the URL doesn't carry. This adds the generator for those rules, plus docs. Nothing here is active — applying the ruleset is a deliberate, separate step, and the rules are scoped to hostnames that opt in via `deco_cdn_html` custom metadata (sites are custom hostnames in one shared zone, so an unscoped rule would enable every site at once). The rules are DERIVED from the same constants the Worker uses (`SEGMENT_COOKIE`, `BOT_UA_SUBSTRINGS`, `DECO_MATCHERS_OVERRIDE_PARAM`) because two hand-maintained copies of that list drift, and the failure mode is one visitor being served another's response. Tests assert exactly that correspondence. Note what the rules deliberately do NOT contain: private paths. The Worker already emits `no-store` for them and Cloudflare honours it, so a site calling `registerPrivatePaths([...])` propagates to the CDN with no rule change. Also exports `BOT_UA_SUBSTRINGS` (previously inlined in a regex) so the bot list has a single definition, and teaches `POST /_cache/purge` to purge the CDN by hostname when `DECO_CF_ZONE_ID` + `DECO_CF_PURGE_TOKEN` are present — inert without them, since `caches.default` is then the only copy anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eview gaps Follow-up to the security review of this PR. The first item is a real bug I introduced, and the shape of it is the worst kind: silent, and certified by a test that encoded the same wrong assumption. ## Cache Rules were relying on first-match-wins, which Cloudflare is not The cache phase is LAST-match-wins for non-terminating actions like `set_cache_settings`. The catch-all `cache: true` rule was a strict superset of the `cache: false` bypass rule, so it overrode it for every request — making the entire bypass list inert, including the auth-cookie clause the file itself calls "the single most important clause". A logged-in visitor would have been served the shared anonymous entry with the Worker never running, which is precisely the case the rules exist to prevent. Both rules are now scoped by expression (`and not (<clauses>)`), so correctness no longer depends on order at all. The test that asserted "evaluates bypass before caching" is replaced by one asserting mutual exclusion — verified to fail if the old shape is reintroduced. ## `sec-fetch-dest: empty` would have bypassed the traffic this feature caches Every `/_serverFn` call is an XHR and sends that header, so once the ordering was fixed that clause would have bypassed exactly what `cdnCacheControl: "serverfn-segment"` exists to cache — quietly reducing the feature to a no-op. Carved out for server-fn paths, mirroring `buildCacheKey`, which excludes them from `__fetch` for the same reason. ## `cdnCacheableServerFn` now fails closed on geo `__cf_geo` is in the Worker key, cannot be expressed in the marker, and cannot be reproduced by the CDN. Mostly moot — with geo on, the `buildSegment` wrapper back-fills `regionId` and any `regionId` already nulls the token — but the back-fill reads only `cf.regionCode` while `buildGeoCacheParam` keys on country/region/city, so a request with a country and no region code slips through. Refused outright, matching what the `match-profile` branch fifteen lines below already did. ## The exit-point comment now describes what the code does It claimed "enforced here, at the single exit, so a future bypass branch can't forget it" while only acting on `X-Cache: BYPASS`. Branches returning before `dressResponse` (`?asJson`, `?renderJson`, proxy, redirects) emitted no `CDN-Cache-Control` at all. `no-store` is now the default when the header is absent, so an early return can only ever be more restrictive than the cache layer; a value `dressResponse` already decided is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correções do security reviewQuatro apontamentos endereçados no 1. As Cache Rules dependiam de first-match-wins — e a Cloudflare não éA fase de cache é last-match-wins para ações não-terminantes como Pior: o teste Agora as duas regras são mutuamente exclusivas por expressão ( 2.
|
|
Fechando em favor de PRs separados, conforme o review — eram duas coisas num PR só.
O gerador de Cache Rules saiu dos dois. O review apontou certo que em sites servidos por Workers não existe cache na frente, e que o caminho é o Workers Cache ( Os quatro fixes do security review que estavam no |
Contexto
Todos os sites TanStack servem HTML e
/_serverFncomCdn-Cache-Control: no-store, logoCf-Cache-Status: BYPASS: nenhuma requisição é servida pelo CDN, todas invocam o Worker.O
no-storeestá correto no caso geral. O Worker chaveia o cache por uma Request sintética que carrega__seg/__cf_device/__cf_geo/__bot/__fetch/__abf(buildCacheKey), enquanto o CDN da Cloudflarechaveia pela URL crua e ignora
Varyalém deAccept-Encoding. Deixar o CDN cachear por URL serviria HTMLde desktop para mobile, de uma região para outra, ou o render eager de um crawler para um humano.
Este PR faz três coisas: libera o CDN para
/_serverFn(onde dá para fazer com segurança), fecha buracos declassificação de rota que o
no-storevinha mascarando, e deixa pronto — inativo — o material paracachear HTML no CDN.
O que muda
1.
/_serverFnno CDN (opt-in)Inverte o problema: em vez de esperar que o CDN reproduza a chave, o cliente põe o segmento na URL
(
?__cseg=<device>.<buildHash>), o que torna a chave do CDN equivalente à do Worker.Default inalterado. O marcador é dica, não fonte de verdade — o Worker recalcula o segmento do próprio
request e só relaxa o
no-storequando confere exatamente.Portado de uma implementação que já roda em produção num site, com dois gaps fechados: aquela versão
verificava só o device, deixando bots (que executam JS, como Lighthouse/PageSpeed) e cohorts de A/B
envenenarem uma entrada compartilhada no CDN.
2. Endurecimento (independe do opt-in)
Estes valem mesmo sem ligar nada, e alguns são bugs reais em produção hoje:
PRIVATE_PREFIX_REtinha buracos graves. Era case-sensitive, ancorada na raiz e cobria uma lista curta,então
/listadedesejos,/wishlist,/favoritos,/orders,/profile,/logout,/cadastroe rotas detroca/devolução caíam no default
listing— público, 120s de edge. Idem/Checkoute/pt/checkout.hasOnlySafeCookiesera fail-open: resposta comset-cookiecujos nomes não foram parseados eratratada como segura, isto é, cacheável.
bypassPathssubstituía os defaults em vez de somar, então um site que acrescentava um path perdiasilenciosamente
/deco/,/live/e/.decofile.CDN-Cache-Control: no-store, garantido no ponto único de saída em vez de emdoze call sites. Alguns deletavam o header, outros não, e vários ainda emitiam o
Cache-Controlpúblico doperfil (
public, s-maxage=900) na saída.buildSegment— sem ele o bypass de logado é inerte e usuário autenticadocompartilha a entrada anônima.
match-profilevirou armadilha silenciosa: o JSDoc prometia segurança "sembuildSegmentedeviceSpecificKeys: false", masdeviceSpecificKeysétruepor default, então a condição é falsa em todosite. Agora é recusado com aviso em vez de cross-servir segmentos.
3. Configuração pelo site: livre para apertar, ruidosa para afrouxar
Como o Worker é a fonte de verdade da elegibilidade, um site pode marcar rotas como privadas à vontade e isso
chega ao CDN sozinho. Afrouxar é o que vaza dado, então ficou deliberadamente incômodo:
registerCachePatterncontinua vencendo os builtins, exceto que não consegue mais tornar pública umarota privada (um pattern amplo do site capturava
/checkout).setCacheProfile("private", { isPublic: true })é recusado com aviso;allowPublicPrivateProfile()é a saída,e ela tem um nome que você precisa digitar.
4. Material para HTML no CDN — presente, não ativado
blocks-cli/scripts/cdn-rules.tsgera as Cache Rules derivadas das mesmas constantes do Worker(
SEGMENT_COOKIE,BOT_UA_SUBSTRINGS,DECO_MATCHERS_OVERRIDE_PARAM) — duas cópias mantidas à mão divergem,e o modo de falhar é servir a resposta de um visitante para outro. Os testes fixam essa correspondência.
As regras não listam rotas privadas de propósito: o Worker já manda
no-storenelas e a Cloudflarerespeita, então
registerPrivatePaths()propaga sem mexer em regra.POST /_cache/purgepassa a purgar o CDN por hostname quandoDECO_CF_ZONE_ID+DECO_CF_PURGE_TOKENexistirem; inerte sem elas.
Validação
Testado end-to-end contra um site real buildado (não só unit): pacotes linkados,
bun run build, worker nowrangler dev, e navegação SPA dirigida por Chrome.Cdn-Cache-Control/_serverFncom marcador correto (desktop e mobile)public, max-age=1800✅no-storeno-storeno-storeno-storeno-storeno-storeno-storeO valor liberado bate exatamente com o que a implementação de site produz em produção. No site, isso
substituiu ~230 linhas de shim por 6 linhas + um
start.tsde 9.Fase 0 conferida no worker real:
/listadedesejos,/Checkoute/pt/minha-contaagora saemprivate, no-store; antes eramlistingpúblico de 120s.2046testes passando,63novos. Typecheck limpo nos três pacotes.O que NÃO está aqui
Cachear HTML no CDN. Não é mudança de header: exige aplicar as Cache Rules na zona, e falta validar que
cf.hostname.metadatafunciona dentro de Cache Rules (a doc da Cloudflare confirma o campo nos motores deregras, mas não afirma suporte em Cache Rules especificamente). O ganho ali também é mais estreito do que
parece — o Cache API do Worker já responde do mesmo edge; o CDN economizaria a invocação do Worker, não a
latência de rede. Vale medir custo de invocação antes de decidir.
🤖 Generated with Claude Code
Summary by cubic
Enables opt-in CDN caching for
/_serverFnrequests: the client attaches a device-plus-build segment to the URL, making the CDN's cache key match the Worker's. Sites keepno-storeby default, the Worker re-verifies the marker and falls back on any mismatch (forged, stale, bot, logged-in, A/B), and HTML documents stay uncacheable — the initial navigation has no client hook.Cache hardening
PRIVATE_PREFIX_REis now case-insensitive, matches behind a locale prefix, and covers wishlist, profile, orders, signup, logout, and returns — routes that previously fell through to the publiclistingprofile.hasOnlySafeCookiesfails closed: unparseableset-cookienames no longer count as safe.bypassPathsnow always extends framework defaults instead of replacing them.no-storeon every bypass and on any response missing the header (early returns), so nothing accidental goes public.match-profileis refused with a warning unless the cache key is exactly the raw URL.registerPrivatePaths()is the new safe way to add private routes.Cache-Controlon every response and clearsCDN-Cache-Controlfor logged-in or custom-pricing requests.CDN rules and purge
blocks-cli/scripts/cdn-rules.tsgenerates Cloudflare Cache Rules derived from the Worker's own constants (SEGMENT_COOKIE,BOT_UA_SUBSTRINGS,DECO_MATCHERS_OVERRIDE_PARAM) so the two cannot drift.sec-fetch-dest: emptybypass carves out/_serverFn, which is exactly the traffic this feature exists to cache.deco_cdn_htmlmetadata and skip private paths, which the Worker'sno-storealready covers.POST /_cache/purgealso purges the CDN by hostname whenDECO_CF_ZONE_IDandDECO_CF_PURGE_TOKENare set.buildSegmentis missing, since the logged-in bypass is inert without it.Written for commit 8b53a3f. Summary will update on new commits.