Skip to content

fix: preserve client IP and VTEX route priority when proxying - #1655

Open
nicacioliveira wants to merge 5 commits into
mainfrom
fix/proxy-preserve-client-ip
Open

fix: preserve client IP and VTEX route priority when proxying#1655
nicacioliveira wants to merge 5 commits into
mainfrom
fix/proxy-preserve-client-ip

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Aug 7, 2026

Copy link
Copy Markdown

Two defects in the proxy path, both surfaced while wiring an A/B test between a deco storefront and a VTEX FastStore. Neither is specific to that store — they affect any deco site using website/handlers/proxy.ts or the VTEX app.

1. x-real-ip never reaches the origin

removeCFHeaders drops every cf-* header, including cf-connecting-ip, and nothing replaces it.

Measured on a live pod, proxying 100% of traffic to a header echo and toggling only the apps/ import:

apps x-real-ip at the origin
0.153.0 absent
this branch 187.61.224.250

x-forwarded-for was already fine — it is not in HOP_BY_HOP and arrives with the client IP first. Hence the second commit: the first version prepended unconditionally and would have produced a duplicate entry.

2. A catch-all /* outranks every VTEX system path

Route rank is (highPriority ? 1000 : 0) + rankRoute(path), and routeFromPath in vtex/loaders/proxy.ts registered without highPriority:

abTesting  /*         highPriority   1000 + 3 = 1003
vtex       /checkout  —                     6

So an A/B audience swallows /checkout, /account, /login, /api/*, /_v/*, /arquivos/* and the rest of PATHS_TO_PROXY — in both arms, since routes are ranked before the matcher runs.

The concrete failure: /checkout proxies to the FastStore, whose checkout route is only

useEffect(() => { window.location.href = config.checkoutUrl }, [])

with checkoutUrl hardcoded to the store origin. That redirects straight back into the catch-all. Infinite loop.

Marking those paths highPriority puts them at 1006, ahead of any catch-all.

Notes

  • removeCFHeaders is left untouched; it is exported and used elsewhere (linx/utils/headers.ts).
  • Downstream CDNs append their own hop to x-forwarded-for, so origins should read the first entry.
  • deno fmt and deno lint pass. deno check reports 2 pre-existing errors in website/utils/crypto.ts, identical on a clean main.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved proxy IP handling for forwarded requests, including IPv4 and IPv6 addresses with port notation.
    • Preserved the connecting client’s IP and set consistent forwarding headers.
    • Ensured built-in system routes take precedence over configured extra paths.

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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 0.161.1 update
  • 🎉 for Minor 0.162.0 update
  • 🚀 for Major 1.0.0 update

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 823bde17-952a-40f0-8e99-63819599f1e8

📥 Commits

Reviewing files that changed from the base of the PR and between 57e4f0c and be46c79.

📒 Files selected for processing (2)
  • vtex/loaders/proxy.ts
  • website/handlers/proxy.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • website/handlers/proxy.ts

📝 Walkthrough

Walkthrough

The proxy normalizes and forwards client IP values through request headers. Generated VTEX system routes now use high priority, while configured extra paths retain default priority.

Changes

Proxy client IP forwarding

Layer / File(s) Summary
Forward client IP headers
website/handlers/proxy.ts
The proxy normalizes IP values, captures cf-connecting-ip before removing Cloudflare headers, updates x-forwarded-for, and sets x-real-ip.

VTEX route priority

Layer / File(s) Summary
Prioritize generated VTEX routes
vtex/loaders/proxy.ts
routeFromPath accepts a priority flag. Built-in VTEX paths receive high priority, while configured extraPaths retain default priority.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • deco-cx/apps#1587: Both PRs modify the proxy handler and VTEX proxy route generation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies both main proxy fixes: client IP preservation and VTEX route priority.
Description check ✅ Passed The description clearly explains both defects, fixes, validation, and scope, but omits the template's Issue Link, Loom Video, and Demonstration Link.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/proxy-preserve-client-ip

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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) <noreply@anthropic.com>
@nicacioliveira nicacioliveira changed the title fix(website): preserve client IP when proxying fix(website): forward x-real-ip when proxying Aug 7, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread website/handlers/proxy.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread website/handlers/proxy.ts Outdated
@nicacioliveira

Copy link
Copy Markdown
Author

Validated end to end on a live deco site (deco-sites/nicacio), proxying 100% of traffic to a header echo, toggling only the apps/ import between 0.153.0 and this branch. Same site, same client, same target.

Before — apps@0.153.0

X-Real-Ip        (absent)
X-Forwarded-For  187.61.224.250,10.3.1.165,10.3.120.66, …

After — apps@73966ef

X-Real-Ip        187.61.224.250
X-Forwarded-For  187.61.224.250,10.3.1.165,10.3.120.66, …

The client IP now reaches the origin via x-real-ip, and x-forwarded-for is unchanged — one entry for the client, no duplicate. That second point is why the branch has two commits: the first version prepended unconditionally, and a live probe showed x-forwarded-for already arrives with the client IP first, so it would have produced 187.61.224.250, 187.61.224.250, ….

Site was restored to 0.153.0 after the run.

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) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vtex/loaders/proxy.ts`:
- Around line 84-88: Update the route construction around routeFromPath so
highPriority: true applies only to entries from PATHS_TO_PROXY, not configured
extraPaths. Ensure extraPaths use normal priority unless they are explicitly
classified as VTEX system paths requiring the same bypass behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0e77c17-a282-4d22-8474-90fc8ad914d9

📥 Commits

Reviewing files that changed from the base of the PR and between ee88a55 and 57e4f0c.

📒 Files selected for processing (2)
  • vtex/loaders/proxy.ts
  • website/handlers/proxy.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • website/handlers/proxy.ts

Comment thread vtex/loaders/proxy.ts Outdated
@nicacioliveira nicacioliveira changed the title fix(website): forward x-real-ip when proxying fix: preserve client IP and VTEX route priority when proxying Aug 7, 2026
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) <noreply@anthropic.com>
@nicacioliveira

Copy link
Copy Markdown
Author

Addressed both cubic findings in 6f69aefe.

P3 (dedup comparison) — valid, fixed. The guard compared raw strings, so IPv6 hex casing differing between hops, or an x-forwarded-for entry carrying a port, would slip past it and prepend a duplicate. Now both sides are compared in canonical form (lowercased, brackets and port stripped). The forwarded value itself is untouched — normalization is for comparison only.

1.2.3.4:56789      -> 1.2.3.4
[2001:DB8::1]:443  -> 2001:db8::1
2001:DB8::1        -> 2001:db8::1

P2 (trust boundary) — real, but not introduced here. x-forwarded-for is not in HOP_BY_HOP and was already forwarded untouched before this PR. An origin reachable outside the CDN could always be fed a forged first entry; deriving x-real-ip from cf-connecting-ip gives an attacker no capability they did not already have by setting x-forwarded-for directly.

Validating against Cloudflare IP ranges is also not enforceable at this layer: the handler runs behind the service mesh, so the peer address it would check is an internal hop, not the edge. Authenticating the edge belongs at the ingress — that is CDN-to-origin auth (mTLS, a shared secret header, or an allowlist), not a header check inside a proxy loader.

Documented the boundary in the code comment as suggested, rather than implementing a check that would look like a guarantee without being one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="website/handlers/proxy.ts">

<violation number="1" location="website/handlers/proxy.ts:26">
P2: Equivalent IPv6 spellings can still be prepended as duplicate client entries because `normalizeIp` is not actually canonical for IPv6. Canonicalize parsed IPv6 (including mapped forms) before the deduplication comparison while preserving the original forwarded value.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread website/handlers/proxy.ts
Comment on lines +26 to +33
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;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Equivalent IPv6 spellings can still be prepended as duplicate client entries because normalizeIp is not actually canonical for IPv6. Canonicalize parsed IPv6 (including mapped forms) before the deduplication comparison while preserving the original forwarded value.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At website/handlers/proxy.ts, line 26:

<comment>Equivalent IPv6 spellings can still be prepended as duplicate client entries because `normalizeIp` is not actually canonical for IPv6. Canonicalize parsed IPv6 (including mapped forms) before the deduplication comparison while preserving the original forwarded value.</comment>

<file context>
@@ -17,6 +17,20 @@ const HOP_BY_HOP = [
+ * 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+)?$/);
</file context>
Suggested change
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;
};
const normalizeIp = (value: string): string => {
const ip = value.trim().toLowerCase();
const bracketed = ip.match(/^\[(.+)\](?::\d+)?$/);
const host = bracketed?.[1] ??
ip.match(/^([\d.]+):\d+$/)?.[1] ??
ip;
if (!host.includes(":")) return host;
try {
return new URL(`http://[${host}]`).hostname.slice(1, -1);
} catch {
return host;
}
};

@nicacioliveira

Copy link
Copy Markdown
Author

Field note for anyone using this alongside an A/B test against a FastStore target: /api/* is contested and the priority bump alone is not enough.

                              FastStore target     legacy platform
/api/graphql                  serves it            no
/api/checkout/pub/orderForm   404                  serves it
/api/sessions                 404                  serves it

Reproduced live: with /api/* correctly winning over the A/B catch-all, the checkout works but every product listing fails — the FastStore’s /api/graphql is now routed to the legacy platform.

Not a defect in this PR: sending /api/* to the VTEX platform is right for a deco storefront, and the collision only exists when the A/B target is itself a VTEX app answering under /api. The site-level fix is one more route, which outranks it naturally:

rankRoute("/api/graphql") = 9   -> 1009 with highPriority
rankRoute("/api/*")       = 6   -> 1006
rankRoute("/*")           = 3   -> 1003
{
  "pathTemplate": "/api/graphql",
  "highPriority": true,
  "handler": { "value": {
    "__resolveType": "website/handlers/proxy.ts",
    "url": "https://<faststore-host>"
  }}
}

Worth calling out in the A/B docs — without it the symptom is a working checkout and empty PLPs, which reads like a data problem rather than a routing one.

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="vtex/loaders/proxy.ts">

<violation number="1" location="vtex/loaders/proxy.ts:103">
P1: A configured duplicate of a VTEX system path loses that path’s priority, allowing the A/B catch-all to swallow checkout/login again. Filter paths already in `PATHS_TO_PROXY` from `extraPaths` so built-ins remain authoritative.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread vtex/loaders/proxy.ts
// 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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A configured duplicate of a VTEX system path loses that path’s priority, allowing the A/B catch-all to swallow checkout/login again. Filter paths already in PATHS_TO_PROXY from extraPaths so built-ins remain authoritative.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vtex/loaders/proxy.ts, line 103:

<comment>A configured duplicate of a VTEX system path loses that path’s priority, allowing the A/B catch-all to swallow checkout/login again. Filter paths already in `PATHS_TO_PROXY` from `extraPaths` so built-ins remain authoritative.</comment>

<file context>
@@ -81,19 +84,24 @@ const buildProxyRoutes = (
+      // 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)),
+    ];
 
</file context>
Suggested change
...extraPaths.map((path) => routeFromPath(path)),
...extraPaths.filter((path) => !PATHS_TO_PROXY.includes(path)).map((path) =>
routeFromPath(path)
),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant