Skip to content

bug(relayer): X-Forwarded-For spoofing bypasses per-IP rate limiting on the unauthenticated MCP proxy routes #360

Description

@anna-stolbovskaja

The MCP SSE/messages proxy derives the client IP for rate limiting by taking the first value from an inbound X-Forwarded-For header, without validating how many trusted proxy hops sit in front of the relayer. These routes intentionally skip signed-request auth and per-key rate limiting (documented in mcp_proxy.rs), so the per-IP session cap is the only per-source defense against an unauthenticated session flood — and it is trivially spoofable.

The Rust relayer appends its own observed peer IP to any client-supplied X-Forwarded-For, leaving the attacker-controlled value at the front — services/server/src/mcp_proxy.rs (inject_forwarded_for):

// appends the real peer IP but never strips/validates a client-supplied
// value already present in the header:
let value = match inbound.get("x-forwarded-for")... {
    Some(existing) => format!("{}, {}", existing, peer_ip),
    None => peer_ip,
};

The Node sidecar then trusts the first (leftmost / attacker-controlled) hop — services/server/scripts/mcp/rateLimit.ts (clientIpFromRequest):

const first = raw.split(",")[0]?.trim();
return first;

The same first-hop pattern also exists in services/server/src/rate_limit.rs (sponsor_rate_limit_middleware):

.and_then(|s| s.split(',').next())   // first hop = attacker-controlled

No trust proxy / hop-count configuration exists anywhere in services/server to bound how many hops are trusted.

Steps to reproduce

# Open many MCP SSE sessions from one host; a fresh fake header each time
# lands in a new per-IP bucket, so maxSessionsPerIp / maxNewSessionsPerIpPerMin
# never trip:
for i in $(seq 1 100); do
  curl -N https://relayer.memory.walrus.xyz/api/mcp/sse \
    -H "X-Forwarded-For: 203.0.113.$((RANDOM % 255))" \
    -H "Authorization: Bearer <any-shape-valid-value>" &
done

Each connection is bucketed under a different attacker-chosen IP, so the per-IP caps in McpRateLimiter.acquire(ip) are never reached.

Impact

  • MCP /api/mcp/sse + /api/mcp/messages (primary): per mcp_proxy.rs, these routes deliberately bypass the relayer's Ed25519 signed-request auth and its per-key rate limiting; the sidecar's per-IP session caps (maxSessionsPerIp, maxNewSessionsPerIpPerMin) are the sole per-source defense. rateLimit.ts is keyed purely on IP (acquire(ip)), with no per-key/per-sender axis. Spoofing the header therefore fully bypasses per-source limiting and lets a single host open long-lived SSE/streamable sessions well past the intended cap — the exact unauthenticated session-flood DoS the limiter's own comments say it exists to prevent. The global maxTotalSessions cap still bounds absolute worst case, but per-IP fairness is defeated.

  • /sponsor + /sponsor/execute (secondary, weaker): the same first-hop pattern in sponsor_rate_limit_middleware is spoofable, but here it is not the only gate. routes/sponsor.rs adds a per-sender rate limit (check_sender_rate_limit, keyed on the request's Sui sender) explicitly described in-code as "a second axis that a distributed IP attack cannot bypass." So spoofing the IP alone does not grant extra Enoki gas-sponsorship throughput — an attacker capped at per_minute/per_hour per sender would additionally have to rotate sender addresses to abuse it. Worth fixing for consistency and defense-in-depth, but the per-sender backstop materially limits real-world impact on this route.

Expected behavior

  1. Configure an explicit trusted-hop count (e.g. axum::extract::ConnectInfo plus a known number of proxies in front of the relayer — 1 for Railway's edge) and take the IP that many hops from the right end of the X-Forwarded-For chain, never the left/first client-supplied value.
  2. Alternatively, if Railway's edge already guarantees a sanitized header (e.g. X-Real-IP or a platform-specific trusted header), prefer that over raw X-Forwarded-For.
  3. Apply the fix consistently to both the MCP proxy path (Rust → Node sidecar) and the /sponsor limiter (Rust).

Environment: Deployed on Railway (railway.json, axum server + Node sidecar per Dockerfile). No trust proxy / hop-count config found in services/server. Verified against dev branch; MCP rate limiter in services/server/scripts/mcp/rateLimit.ts is IP-keyed only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions