From a98acd0ed134666dc9b302c45535d1cc8e6b5c12 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 29 Jul 2026 13:30:11 -0300 Subject: [PATCH] fix(vtex,blocks): prevent caching authenticated proxy responses (cross-user leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated VTEX proxy responses (/account, /api/sessions, /api/oms/user/orders) could be served with `Cache-Control: public` and cached at shared edge layers, leaking one user's orders/data to another. Root cause was threefold (see #412): - apps-vtex/utils/proxy.ts: neither proxyToVtex nor createVtexCheckoutProxy normalized Cache-Control, so the raw origin directive (or a public listing profile) passed through. Now force `private, no-store` + `CDN-Cache-Control: no-store` on authenticated proxy responses by default (opt-out via hardenAuthenticatedCache), exempting static assets. - apps-vtex/utils/vtexId.ts: extractVtexAuthCookie ignored the account-suffixed cookie variant, and parseVtexAuthToken treated opaque (non-JWT) tokens as logged-out — so isLoggedIn was false for real sessions and any logged-in cache bypass never fired. Match the suffixed variant and treat a present opaque token as logged-in. - blocks/sdk/cacheHeaders.ts: PRIVATE_PREFIX_RE missed `myaccount` and pt-BR account routes, letting them fall through to the cacheable `listing` default. Cover them. Refs #412 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/apps-vtex/src/utils/proxy.ts | 60 +++++++++++++++++++++++++ packages/apps-vtex/src/utils/vtexId.ts | 17 ++++++- packages/blocks/src/sdk/cacheHeaders.ts | 6 ++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/apps-vtex/src/utils/proxy.ts b/packages/apps-vtex/src/utils/proxy.ts index bff7e044..b9568d39 100644 --- a/packages/apps-vtex/src/utils/proxy.ts +++ b/packages/apps-vtex/src/utils/proxy.ts @@ -45,6 +45,14 @@ export interface VtexProxyOptions { * Custom headers to inject into every proxied request. */ extraHeaders?: Record; + + /** + * Force `Cache-Control: private, no-store` + `CDN-Cache-Control: no-store` + * on authenticated proxy responses (everything except static assets), so a + * personalized VTEX response can never be cached and served across users. + * @default true + */ + hardenAuthenticatedCache?: boolean; } const DEFAULT_PROXY_PATHS = [ @@ -79,6 +87,42 @@ const HOP_BY_HOP_HEADERS = new Set([ "upgrade", ]); +/** + * Path prefixes whose proxied responses are safe to cache publicly — genuinely + * public static assets (media files, VTEX store-theme bundles). Everything else + * routed through the VTEX proxy is authenticated/personalized by nature + * (account, checkout, /api/sessions, orders, graphql, VTEX ID) and MUST NOT be + * cached by shared/edge layers. + */ +const PUBLIC_PROXY_ASSET_PREFIXES = [ + "/files/", + "/arquivos/", + "/assets/vtex", + "/XMLData/", +]; + +function isPublicProxyAsset(pathname: string): boolean { + return PUBLIC_PROXY_ASSET_PREFIXES.some((p) => pathname.startsWith(p)); +} + +/** + * Force authenticated proxy responses to be non-cacheable on every shared layer. + * + * VTEX marks some endpoints `no-store` (e.g. `/api/sessions`) but not all + * (`/account` comes back `public`), and the raw origin `Cache-Control` must + * never let a personalized response be cached across users. Without this, a + * logged-in user's response can be stored at the edge and served to another + * user — the root cause of a cross-customer data leak (see decocms/blocks#412). + * Static assets are exempt so store-theme/media caching is preserved. + */ +function hardenProxyCacheHeaders(headers: Headers, pathname: string): void { + if (isPublicProxyAsset(pathname)) return; + headers.set("Cache-Control", "private, no-store, no-cache, must-revalidate"); + // CDN-Cache-Control takes precedence for Cloudflare's CDN layer. + headers.set("CDN-Cache-Control", "no-store"); + headers.delete("Surrogate-Control"); +} + /** * Returns all path prefixes that should be proxied to VTEX. */ @@ -199,6 +243,10 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions): } } + if (options?.hardenAuthenticatedCache !== false) { + hardenProxyCacheHeaders(responseHeaders, requestUrl.pathname); + } + return new Response(originResponse.body, { status: originResponse.status, statusText: originResponse.statusText, @@ -254,6 +302,14 @@ export interface VtexCheckoutProxyConfig { * Receives the full HTML string and should return the modified version. */ htmlTransform?: (html: string) => string; + + /** + * Force `Cache-Control: private, no-store` + `CDN-Cache-Control: no-store` + * on authenticated proxy responses (everything except static assets), so a + * personalized VTEX response can never be cached and served across users. + * @default true + */ + hardenAuthenticatedCache?: boolean; } const CF_INTERNAL_HEADERS = new Set([ @@ -414,6 +470,10 @@ export function createVtexCheckoutProxy( } } + if (config.hardenAuthenticatedCache !== false) { + hardenProxyCacheHeaders(resHeaders, url.pathname); + } + // HTML transform for checkout pages const ct = originRes.headers.get("content-type") ?? ""; if (config.htmlTransform && ct.includes("text/html")) { diff --git a/packages/apps-vtex/src/utils/vtexId.ts b/packages/apps-vtex/src/utils/vtexId.ts index 107ab368..42c632a3 100644 --- a/packages/apps-vtex/src/utils/vtexId.ts +++ b/packages/apps-vtex/src/utils/vtexId.ts @@ -20,9 +20,17 @@ const VTEX_AUTH_COOKIE = "VtexIdclientAutCookie"; /** * Extract the VtexIdclientAutCookie value from a cookie string. + * + * Matches BOTH the base cookie `VtexIdclientAutCookie=` and the account-suffixed + * variant `VtexIdclientAutCookie_{account}=`. VTEX frequently sets ONLY the + * suffixed variant on the storefront domain — matching just the base name makes + * genuinely logged-in users look anonymous, which defeats any logged-in cache + * bypass built on top of this and can leak personalized/cached responses. */ export function extractVtexAuthCookie(cookieHeader: string): string | null { - const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}=([^;]+)`)); + const match = cookieHeader.match( + new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}(?:_[^=;\\s]+)?=([^;]+)`), + ); return match?.[1] ?? null; } @@ -48,7 +56,12 @@ function decodeJwtPayload(token: string): Record | null { export function parseVtexAuthToken(token: string): VtexAuthInfo { const payload = decodeJwtPayload(token); if (!payload) { - return { isLoggedIn: false, isExpired: true }; + // Opaque (non-JWT) token. VTEX auth cookies are frequently opaque strings + // rather than JWTs — we cannot read `exp`, but the mere presence of the + // cookie means the user IS authenticated. Treating it as logged-out (the + // previous behavior) makes real sessions look anonymous and defeats any + // logged-in cache bypass. Fail towards "logged in" (i.e. do-not-cache). + return { isLoggedIn: true, isExpired: false }; } const exp = typeof payload.exp === "number" ? payload.exp : undefined; diff --git a/packages/blocks/src/sdk/cacheHeaders.ts b/packages/blocks/src/sdk/cacheHeaders.ts index 52fddc21..62777191 100644 --- a/packages/blocks/src/sdk/cacheHeaders.ts +++ b/packages/blocks/src/sdk/cacheHeaders.ts @@ -262,8 +262,12 @@ interface CachePattern { profile: CacheProfileName; } +// Authenticated / per-user areas that must never be edge-cached. Includes the +// hyphenless `myaccount` (VTEX My Account wrapper path) and common pt-BR routes +// (`minha-conta`, `meus-pedidos`, `pedidos`) — omitting these let account pages +// fall through to the cacheable `listing` default (see decocms/blocks#412). const PRIVATE_PREFIX_RE = - /^\/(cart|checkout|account|login|my-account)(\/|$)/; + /^\/(cart|checkout|account|myaccount|my-account|minha-conta|meus-pedidos|pedidos|login)(\/|$)/; const builtinPatterns: CachePattern[] = [ {