Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/apps-vtex/src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export interface VtexProxyOptions {
* Custom headers to inject into every proxied request.
*/
extraHeaders?: Record<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 DEFAULT_PROXY_PATHS = [
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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")) {
Expand Down
17 changes: 15 additions & 2 deletions packages/apps-vtex/src/utils/vtexId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -48,7 +56,12 @@ function decodeJwtPayload(token: string): Record<string, unknown> | 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;
Expand Down
6 changes: 5 additions & 1 deletion packages/blocks/src/sdk/cacheHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down