Skip to content

[P0/security] VTEX proxy serves authenticated responses as Cache-Control: public → cross-user data leak #412

Description

@JonasJesus42

Impact (P0, security)

On a production VTEX storefront (@decocms/tanstack + @decocms/apps-vtex, 2026-07-29), customers reported — and we reproduced — seeing other customers' orders and personal data on the "my orders" (/myaccount/account) area. Root cause: authenticated VTEX responses routed through the worker proxy are served with Cache-Control: public and cached at shared edge layers (Cloudflare/CloudFront), delivering one user's response to another.

Reproduction (production, anonymous requests)

# /api/sessions DIRECT to VTEX origin:
cache-control: no-store,no-cache

# /api/sessions THROUGH the worker proxy:
cache-control: public, s-maxage=60, stale-while-revalidate=3600, stale-if-error=86400

# /account through the worker:
cf-cache-status: EXPIRED   age: 413   x-cache: Hit from cloudfront   x-vtex-cache-status: EXPIRED

VTEX correctly marks /api/sessions as no-store; it is served as public after passing through our edge path. /account (personalized) is demonstrably served from shared caches.

Three contributing defects

1. Proxy does not normalize Cache-Control (packages/apps-vtex/src/utils/proxy.ts)

createVtexCheckoutProxyfilterHeadersStrict (~276-287) strips hop-by-hop / CF-internal / Set-Cookie but passes Cache-Control through untouched. The proxyHandler return in packages/tanstack/src/sdk/workerEntry.ts:1795-1797 is returned raw (no dressResponse). So authenticated proxy responses get no private/no-store protection.

Fix: default proxy responses to Cache-Control: private, no-store, no-cache, must-revalidate + CDN-Cache-Control: no-store, with an allow-list for genuinely public static assets (/files/, /arquivos/, /assets/vtex, /XMLData/). Consider a hardenAuthenticatedCache option (default true).

2. Login detection breaks for opaque token & account-suffixed cookie (packages/apps-vtex/src/utils/vtexId.ts)

The edge cache's only logged-in bypass depends on extractVtexContext().isLoggedIn. But extractVtexAuthCookie (24-27) only matches the base VtexIdclientAutCookie= (ignores VtexIdclientAutCookie_{account}), and parseVtexAuthToken (48-51) treats any non-JWT (opaque) token as logged-out. VTEX auth cookies are commonly opaque → isLoggedIn is false for genuinely logged-in users → the bypass never fires.

Fix: match the account-suffixed variant too (as parseCookie at 115-136 already does); treat presence of a non-expired auth cookie as logged-in even when opaque.

3. PRIVATE_PREFIX_RE misses /myaccount (packages/blocks/src/sdk/cacheHeaders.ts:265)

/^\/(cart|checkout|account|login|my-account)(\/|$)/ does not match myaccount (no hyphen), the real account path on this store, nor pt-BR routes (meus-pedidos, minha-conta, pedidos). These fall through to the cacheable listing default.

Fix: cover myaccount; optionally allow sites to register extra private prefixes.

Workarounds applied on the site (baggagio-tanstack)

  • hardenAuthenticatedProxyResponse() wraps proxy returns in worker-entry.ts, forcing private, no-store + CDN-Cache-Control: no-store (exempting static-asset prefixes).
  • cache-config.ts registers /myaccount*, /minha-conta*, /meus-pedidos*, /pedidos* as private.
  • Cloudflare Cache Rule bypass on authenticated paths as immediate mitigation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions