Skip to content

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

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

fix: preserve client IP and VTEX route priority when proxying#1655
nicacioliveira wants to merge 4 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

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

Warning

Review limit reached

@nicacioliveira, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 seconds

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99cf91ac-8487-4410-8844-7812c6723626

📥 Commits

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

📒 Files selected for processing (1)
  • website/handlers/proxy.ts
📝 Walkthrough

Walkthrough

The proxy forwards the client IP from cf-connecting-ip through request headers. Generated VTEX system routes now use high priority so they outrank catch-all audience routes.

Changes

Proxy client IP forwarding

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

VTEX route priority

Layer / File(s) Summary
Prioritize generated VTEX routes
vtex/loaders/proxy.ts
routeFromPath sets highPriority: true for generated VTEX proxy routes, including system paths such as checkout, account, login, /api, and /_v.

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

Possibly related PRs

  • deco-cx/apps#1633: Both PRs modify request-header handling in website/handlers/proxy.ts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes both primary changes: preserving client IP data and increasing VTEX route priority.
Description check ✅ Passed The description clearly explains both defects, implementation changes, validation results, and known pre-existing errors, but omits issue, Loom, and demonstration links.
✨ 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
Comment on lines +84 to +88
// 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,

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify how extraPaths are defined and whether callers expect normal priority.
rg -n -C 8 '\bextraPaths\b|\bPATHS_TO_PROXY\b|\bhighPriority\b' --glob '*.ts' .

Repository: deco-cx/apps

Length of output: 24295


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'vtex proxy relevant section:\n'
sed -n '1,120p' vtex/loaders/proxy.ts | cat -n

printf '\nwebsite router relevant section:\n'
sed -n '1,180p' website/handlers/router.ts | cat -n

printf '\nwebsite audience/mod relevant section:\n'
sed -n '1,260p' website/mod.ts | cat -n

printf '\nreferences to extraPathsToProxy and extraPaths in vtex:\n'
rg -n -C 6 'extraPaths(ToProxy)?|PathsToProxy' vtex --glob '*.ts'

Repository: deco-cx/apps

Length of output: 23663


Keep extraPaths out of the high-priority block.

routeFromPath is closed over highPriority: true and invoked for both PATHS_TO_PROXY and every configured extraPaths. Use normal priority for extraPaths, or explicitly document these routes as VTEX system paths that must bypass A/B test catch-all routes.

🤖 Prompt for 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.

In `@vtex/loaders/proxy.ts` around lines 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.

@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.

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