From ee88a550c452452cb25a57d1867431938bb3d839 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Fri, 7 Aug 2026 11:46:28 -0300 Subject: [PATCH 1/5] fix(website): preserve client IP when proxying removeCFHeaders drops every cf-* header, including cf-connecting-ip, so proxied origins saw only the pod's IP. Capture it before the strip and forward it as x-forwarded-for/x-real-ip. Affects every site using website/handlers/proxy.ts, including the VTEX proxy routes and A/B testing via the abTesting prop, where the origin otherwise loses geo, rate limiting, analytics and fraud signals. Co-Authored-By: Claude Opus 5 (1M context) --- website/handlers/proxy.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/website/handlers/proxy.ts b/website/handlers/proxy.ts index 278d40f1b..4c9f0d280 100644 --- a/website/handlers/proxy.ts +++ b/website/handlers/proxy.ts @@ -145,7 +145,19 @@ export default function Proxy({ if (isFreshCtx(_ctx)) { _ctx?.state?.monitoring?.logger?.log?.("proxy received headers", headers); } + // cf-connecting-ip carries the real client IP, and removeCFHeaders is about + // to drop it. Forward it as x-forwarded-for/x-real-ip so the proxied origin + // still sees who the visitor is (geo, rate limiting, analytics, fraud). + const clientIp = headers.get("cf-connecting-ip"); removeCFHeaders(headers); // cf-headers are not ASCII-compliant + if (clientIp) { + const forwardedFor = headers.get("x-forwarded-for"); + headers.set( + "x-forwarded-for", + forwardedFor ? `${clientIp}, ${forwardedFor}` : clientIp, + ); + headers.set("x-real-ip", clientIp); + } if (removeDirtyCookies) { removeDirtyCookiesFn(headers); } From 73966ef04f448935918e725f1e2e4847eeb6ecd6 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Fri, 7 Aug 2026 11:48:43 -0300 Subject: [PATCH 2/5] fix(website): do not duplicate client IP in x-forwarded-for Measured on a live pod: x-forwarded-for already reaches the handler with the client IP as its first entry, so unconditionally prepending it produced a duplicate. Only seed the header when absent, and always set x-real-ip, which was the header actually missing. Co-Authored-By: Claude Opus 5 (1M context) --- website/handlers/proxy.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/website/handlers/proxy.ts b/website/handlers/proxy.ts index 4c9f0d280..bc7cae6d1 100644 --- a/website/handlers/proxy.ts +++ b/website/handlers/proxy.ts @@ -145,17 +145,18 @@ export default function Proxy({ if (isFreshCtx(_ctx)) { _ctx?.state?.monitoring?.logger?.log?.("proxy received headers", headers); } - // cf-connecting-ip carries the real client IP, and removeCFHeaders is about - // to drop it. Forward it as x-forwarded-for/x-real-ip so the proxied origin - // still sees who the visitor is (geo, rate limiting, analytics, fraud). + // cf-connecting-ip carries the real client IP and removeCFHeaders is about + // to drop it, leaving the proxied origin without x-real-ip. x-forwarded-for + // usually already arrives with the client IP first, so only fill the gaps. const clientIp = headers.get("cf-connecting-ip"); removeCFHeaders(headers); // cf-headers are not ASCII-compliant if (clientIp) { const forwardedFor = headers.get("x-forwarded-for"); - headers.set( - "x-forwarded-for", - forwardedFor ? `${clientIp}, ${forwardedFor}` : clientIp, - ); + if (!forwardedFor) { + headers.set("x-forwarded-for", clientIp); + } else if (forwardedFor.split(",")[0].trim() !== clientIp) { + headers.set("x-forwarded-for", `${clientIp}, ${forwardedFor}`); + } headers.set("x-real-ip", clientIp); } if (removeDirtyCookies) { From 57e4f0cc7a4d9fa9955d5aa5eeda7a9593f6cc28 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Fri, 7 Aug 2026 13:48:45 -0300 Subject: [PATCH 3/5] fix(vtex): give VTEX system paths route priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATHS_TO_PROXY covers checkout, account, login, /api/*, /_v/*, /arquivos/* and friends, but the routes were registered without highPriority. Route rank is (highPriority ? 1000 : 0) + rankRoute(path), so an A/B audience registering `/*` with highPriority scores 1003 and outranks `/checkout` at 6 — the catch-all swallows every VTEX system path, in both arms. Concretely on a FastStore A/B: /checkout proxies to the FastStore, whose checkout route only does `window.location.href = checkoutUrl`, pointing back at the same origin. Infinite redirect. Co-Authored-By: Claude Opus 5 (1M context) --- vtex/loaders/proxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vtex/loaders/proxy.ts b/vtex/loaders/proxy.ts index 322498c0a..6fbd8bab7 100644 --- a/vtex/loaders/proxy.ts +++ b/vtex/loaders/proxy.ts @@ -81,6 +81,11 @@ const buildProxyRoutes = ( return ({ pathTemplate, + // These are VTEX system paths — checkout, account, login, /api, /_v. + // Without the priority bump a catch-all `/*` route from an A/B test + // audience outranks them (1000 + rank("/*") = 1003 beats rank + // ("/checkout") = 6) and swallows the whole platform surface. + highPriority: true, handler: { value: handlerValue, }, From 6f69aefe810b403992f2b0d105ba2604481e638a Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Fri, 7 Aug 2026 14:48:08 -0300 Subject: [PATCH 4/5] fix(website): normalize IPs before the x-forwarded-for dedup check The guard compared raw strings, so an IPv6 client whose casing differs between hops, or an x-forwarded-for entry carrying a port, would slip past it and get its IP prepended a second time. Compare canonical forms instead; the forwarded value is untouched. Also documents the trust boundary: x-forwarded-for is already forwarded untouched, so deriving x-real-ip from cf-connecting-ip adds no new spoofing surface. Authenticating the edge belongs at the ingress. Co-Authored-By: Claude Opus 5 (1M context) --- website/handlers/proxy.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/website/handlers/proxy.ts b/website/handlers/proxy.ts index bc7cae6d1..8b09dc95a 100644 --- a/website/handlers/proxy.ts +++ b/website/handlers/proxy.ts @@ -17,6 +17,20 @@ const HOP_BY_HOP = [ const noTrailingSlashes = (str: string) => str.at(-1) === "/" ? str.slice(0, -1) : str; const sanitize = (str: string) => str.startsWith("/") ? str : `/${str}`; +/** + * Canonical form of an IP for comparison only — never for forwarding. + * x-forwarded-for entries may be bracketed and carry a port ([::1]:443, + * 1.2.3.4:56789) and IPv6 hex casing varies between hops; cf-connecting-ip + * is always a bare address. + */ +const normalizeIp = (value: string): string => { + const ip = value.trim().toLowerCase(); + const bracketed = ip.match(/^\[(.+)\](?::\d+)?$/); + if (bracketed) return bracketed[1]; + const ipv4WithPort = ip.match(/^([\d.]+):\d+$/); + if (ipv4WithPort) return ipv4WithPort[1]; + return ip; +}; export const removeCFHeaders = (headers: Headers) => { headers.forEach((_value, key) => { if (key.startsWith("cf-")) { @@ -148,13 +162,21 @@ export default function Proxy({ // cf-connecting-ip carries the real client IP and removeCFHeaders is about // to drop it, leaving the proxied origin without x-real-ip. x-forwarded-for // usually already arrives with the client IP first, so only fill the gaps. + // + // Trust boundary: these headers are only as trustworthy as the ingress in + // front of this handler. x-forwarded-for is already forwarded untouched, so + // an origin reachable outside the CDN could always be fed a forged first + // entry — deriving x-real-ip from cf-connecting-ip does not widen that. + // Authenticating the edge belongs at the ingress, not here. const clientIp = headers.get("cf-connecting-ip"); removeCFHeaders(headers); // cf-headers are not ASCII-compliant if (clientIp) { const forwardedFor = headers.get("x-forwarded-for"); if (!forwardedFor) { headers.set("x-forwarded-for", clientIp); - } else if (forwardedFor.split(",")[0].trim() !== clientIp) { + } else if ( + normalizeIp(forwardedFor.split(",")[0]) !== normalizeIp(clientIp) + ) { headers.set("x-forwarded-for", `${clientIp}, ${forwardedFor}`); } headers.set("x-real-ip", clientIp); From be46c79f2bb65a688115a227a1ce0e5bddd96c7e Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Mon, 10 Aug 2026 08:07:43 -0300 Subject: [PATCH 5/5] fix(vtex): only prioritize built-in paths, never extraPathsToProxy Marking every generated route highPriority also promoted extraPathsToProxy, which is site configuration and routinely holds a `/*` fallback for pages the storefront does not implement. At rank 1003 that fallback outranked the storefront's own page routes and proxied the entire site to the platform. Caught on a preview environment: every path returned the legacy VTEX store instead of the deco storefront. Only PATHS_TO_PROXY is promoted now. Co-Authored-By: Claude Opus 5 (1M context) --- vtex/loaders/proxy.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/vtex/loaders/proxy.ts b/vtex/loaders/proxy.ts index 6fbd8bab7..8ce08faa3 100644 --- a/vtex/loaders/proxy.ts +++ b/vtex/loaders/proxy.ts @@ -68,7 +68,10 @@ const buildProxyRoutes = ( const urlToProxy = `https://${hostname}`; const hostToUse = hostname; - const routeFromPath = (pathTemplate: string): Route => { + const routeFromPath = ( + pathTemplate: string, + highPriority?: boolean, + ): Route => { const handlerValue = { __resolveType: "website/handlers/proxy.ts", url: urlToProxy, @@ -81,19 +84,24 @@ const buildProxyRoutes = ( return ({ pathTemplate, - // These are VTEX system paths — checkout, account, login, /api, /_v. - // Without the priority bump a catch-all `/*` route from an A/B test - // audience outranks them (1000 + rank("/*") = 1003 beats rank - // ("/checkout") = 6) and swallows the whole platform surface. - highPriority: true, + highPriority, handler: { value: handlerValue, }, }); }; - const routesFromPaths = [...PATHS_TO_PROXY, ...extraPaths].map( - routeFromPath, - ); + const routesFromPaths = [ + // PATHS_TO_PROXY are VTEX system paths — checkout, account, login, /api, + // /_v. They must win over any catch-all: an A/B audience registering `/*` + // with priority scores 1000 + rank("/*") = 1003 and would otherwise + // outrank rank("/checkout") = 6, breaking cart and login. + ...PATHS_TO_PROXY.map((path) => routeFromPath(path, true)), + // extraPaths is site configuration and routinely contains its own + // catch-alls (`/*`, `/section/*`) used as a fallback for pages the + // storefront does not implement. Promoting those would let the fallback + // outrank the storefront's own pages and swallow the entire site. + ...extraPaths.map((path) => routeFromPath(path)), + ]; const [include, routes] = generateDecoSiteMap ? [[...(includeSiteMap ?? []), decoSiteMapUrl], [{