Skip to content
RY

Possible IP Spoofing in Action Dispatch RemoteIp (GHSA-2gff-2mmg-m73p)

Ram Laxman Yadav9 min read

On November 4, 2026, Rails maintainer Mike Dalessio (flavorjones) published GHSA-2gff-2mmg-m73p: Possible IP Spoofing in Action Dispatch RemoteIp, credited to HackerOne researcher luryus. It’s rated High, CVSS 3.0 score 7.4, and it affects every app on Rails >= 8.1.0, < 8.1.4.1 running on Rack 3. If your app trusts request.remote_ip for anything security-relevant — an IP allowlist, rate limiting, audit logging — this one is worth reading closely, because the bug isn’t in your code. It’s in how Rails itself decided which header to believe.

1. Summary

Since Rails 8.1.0, ActionDispatch::RemoteIp started getting the client’s IP address from Rack’s forwarded_for method instead of parsing X-Forwarded-For directly. That sounds like a harmless internal refactor — and it was intended to be one, done so Rails could reuse Rack’s handling of forwarded port numbers. The catch: under Rack 3, forwarded_for prefers the newer, standardized Forwarded header (RFC 7239) over the older X-Forwarded-For header, if both are present. Rails’ existing anti-spoofing check, though, only ever compared the Client-Ip header against X-Forwarded-For — it had no idea the Forwarded header could now be the thing actually deciding request.remote_ip, and so a client-supplied Forwarded header sailed through completely unchecked.

The practical result: an unauthenticated attacker who can reach your app (directly, or through a proxy that doesn’t strip inbound headers) can set their own Forwarded header and have Rails believe they’re connecting from any IP address they choose.

  • Affected: actionpack (Rails) >= 8.1.0, < 8.1.4.1, running on Rack 3 specifically (Rack 2.2 only ever reads X-Forwarded-For, so it isn’t affected by this particular bug).
  • Fixed in: actionpack 8.1.4.1.
  • Weakness class: CWE-348, Use of a Less Trusted Source.
  • No CVE has been assigned yet as of this advisory’s publication — it’s tracked by its GitHub Security Advisory ID.

Warning

This only bites you if all three of these are true: you’re on an affected Rails version with Rack 3, your reverse proxy passes through a client-supplied Forwarded header unchanged (rather than stripping or overwriting it), and your app actually uses request.remote_ip for something that matters — access control, rate_limit, or IP logging you rely on for forensics. If your proxy already strips arbitrary incoming headers and sets its own, you’re already partially protected regardless of your Rails version — but you should still upgrade.

2. Explanation — what actually went wrong, in plain English

To understand the bug, you need to know there are two different headers proxies use to tell an app “here’s who’s really connecting, I’m just relaying the request”:

  • X-Forwarded-For — the old, de facto standard. Looks like X-Forwarded-For: 203.0.113.7, 10.0.0.2. Not officially standardized, but universally supported.
  • Forwarded — the newer, RFC 7239 standard, meant to eventually replace it. Looks like Forwarded: for=203.0.113.7;proto=https.

Both headers exist to carry the same piece of information — the original client’s address — through one or more proxy hops. And here’s the part that makes this bug possible: both headers are, by default, just as forgeable as each other if nothing strips them. Any HTTP client, including a browser via JavaScript or a plain curl command, can set either header to whatever value it wants. The only reason either header is ever trustworthy is that a well-configured reverse proxy sits in front of your app and overwrites them with the real connecting IP before forwarding the request — discarding whatever the original client tried to claim.

Rails has always known X-Forwarded-For is spoofable this way. That’s exactly why ActionDispatch::RemoteIp ships with a built-in spoofing check (ip_spoofing_check, on by default): if a request arrives with both a Client-Ip header and an X-Forwarded-For header, and they disagree about the client’s address, Rails raises ActionDispatch::RemoteIp::IpSpoofAttackError instead of quietly picking one. That check has existed for years and, until Rails 8.1, it was comparing the two headers that actually mattered.

What changed in 8.1: a refactor (commit 2d084af8) switched RemoteIp#calculate_ip to resolve the client IP through Rack’s own forwarded_for/client_ip methods, so Rails didn’t have to duplicate Rack’s logic for handling ports embedded in forwarded addresses. Reasonable goal. But Rack 3’s forwarded_for, when both headers are present, prefers Forwarded over X-Forwarded-For by default. So from Rails 8.1.0 onward, request.remote_ip could be silently sourced from the Forwarded header — a header the spoofing check had never been taught to look at.

The vulnerable flow, step by step:

  1. A reverse proxy (say, a plain Nginx config that doesn’t touch the Forwarded header at all) sits in front of a Rails 8.1 app.
  2. An attacker sends a request directly, setting their own Forwarded header:
    Terminal window
    curl https://your-app.example.com/admin \
    -H "Forwarded: for=10.0.0.1"
  3. Nginx passes the request through unchanged — it never set X-Forwarded-For itself in this scenario, so there’s nothing for Rails to cross-check the Forwarded value against.
  4. Rack 3’s forwarded_for reads the attacker’s Forwarded header, since X-Forwarded-For is absent.
  5. ActionDispatch::RemoteIp trusts that result as request.remote_ip — because the check only runs a comparison when both headers are present, and here only one is.
  6. Your app now believes this request came from 10.0.0.1 — which, say, happens to be in your internal admin-panel allowlist.

A concrete example of the kind of code this breaks:

class AdminController < ApplicationController
ALLOWED_IPS = %w[10.0.0.1 10.0.0.2].freeze
before_action :verify_internal_network
def verify_internal_network
unless ALLOWED_IPS.include?(request.remote_ip)
head :forbidden
end
end
end

Before this bug, that before_action was reasonably sound as long as your proxy was trustworthy — the usual caveat for any IP-based check. After the Rails 8.1 regression, it became bypassable with nothing more than one extra request header, regardless of how well-configured your proxy otherwise was, specifically because the proxy had no reason to ever think about the Forwarded header — most Nginx/HAProxy configs written before RFC 7239 became common only ever manage X-Forwarded-For.

The same gap applies directly to Rails’ own built-in rate limiter:

class SessionsController < ApplicationController
rate_limit to: 5, within: 1.minute, only: :create
end

rate_limit’s default by: is request.remote_ip — meaning, under the vulnerable versions, an attacker could rotate the value of a Forwarded header on every login attempt and never trip the same rate-limit bucket twice, turning a 5-attempts-per-minute brute-force guard into no guard at all.

Note

Why does Rack 2.2 escape this? Because Rack 2.2 never implements forwarded_for reading the RFC 7239 Forwarded header at all — it only ever parses X-Forwarded-For. The vulnerability is specifically a product of Rails 8.1 adopting Rack 3’s forwarded-header resolution without updating its own spoofing check to match.

3. Fixes

Fix 1 (do this first): upgrade actionpack

# Gemfile
gem "rails", ">= 8.1.4.1"
# or, if you manage actionpack independently:
gem "actionpack", ">= 8.1.4.1"
Terminal window
bundle update actionpack

The patched remote_ip.rb adds a real comparison between the two headers. With config.action_dispatch.ip_spoofing_check left at its default (true), Rails now takes the first untrusted address out of Forwarded and the first out of X-Forwarded-For, and if both headers are present and those two values don’t match as exact strings, it raises:

ActionDispatch::RemoteIp::IpSpoofAttackError:
IP spoofing attack?!
HTTP_CLIENT_IP: ...
HTTP_X_FORWARDED_FOR: ...
HTTP_FORWARDED: ...

— loudly, with a 500, rather than silently picking the attacker’s preferred value.

Warning

Read that fix carefully: it only fires when both headers are present and disagree. If your proxy sets no X-Forwarded-For at all (common on setups that only ever dealt in X-Forwarded-For or Client-Ip before, or that recently moved to Forwarded-only), a spoofed Forwarded header still has nothing to be cross-checked against — upgrading closes the cross-check gap, it doesn’t replace the need for a proxy you actually trust.

Fix 2: make your reverse proxy stop trusting client input, regardless of Rails version

This is the fix the advisory calls out as the real root-cause mitigation, and it protects you even if you can’t upgrade Rails immediately: never let a client-supplied Forwarded or X-Forwarded-For header reach your app unmodified. Your edge proxy should always overwrite these headers with values it knows are correct, not merge or forward whatever arrived from outside.

For Nginx:

# Overwrite — don't just append — so a client-sent value can never survive the hop.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Forwarded "for=$remote_addr;proto=$scheme";

For HAProxy:

http-request del-header Forwarded
http-request del-header X-Forwarded-For
http-request set-header X-Forwarded-For %[src]

The principle is the same regardless of which proxy you run: strip whatever the client sent for these headers, then set them yourself from data your proxy actually observed about the TCP connection.

Fix 3: if you can’t upgrade Rails yet, pin Rack’s behavior back to 8.0’s

If your proxy already reliably sets X-Forwarded-For and you just need Rails to ignore Forwarded entirely in the interim, add this to an initializer:

config/initializers/forwarded_priority.rb
Rack::Request.forwarded_priority = [:x_forwarded]

This requires Rack >= 3.0.0, and it restores Rails 8.0’s behavior of only ever resolving the client address (and scheme/host/port) from the X-Forwarded-* family of headers, ignoring Forwarded entirely. It’s a reasonable stop-gap, but it’s a workaround, not a replacement for actually upgrading actionpack — treat it as something to remove once you’re on 8.1.4.1+.

Verifying the fix

After upgrading, confirm the check actually trips on a deliberately conflicting pair of headers in a non-production environment:

Terminal window
curl -v https://staging.example.com/ \
-H "X-Forwarded-For: 203.0.113.9" \
-H "Forwarded: for=10.0.0.1"

You should get a 500 with IpSpoofAttackError in your logs, not a 200 that quietly picked one of the two addresses.

4. Conclusion

This advisory is a good reminder that “trust the proxy” is only as safe as “the proxy actually overwrites what the client sent” — and that a seemingly internal refactor (swapping which library resolves a header) can silently widen what counts as a trusted source, even when the original spoofing protection is still technically running. Rails 8.1’s move to Rack 3’s forwarded_for was a reasonable piece of consolidation; the bug was that the security check sitting right next to it wasn’t updated to know a second header could now decide the outcome.

Two things to walk away with: upgrade to actionpack 8.1.4.1+ immediately if you’re on an affected version, since the cross-check is genuinely stronger there — and separately, audit your reverse proxy configuration for whether it ever lets a client-supplied Forwarded or X-Forwarded-For header pass through untouched. The Rails-level fix narrows the window; a proxy that never trusts client input in the first place closes it, regardless of which Rails version you’re running or which header standard wins next time.

Source: GHSA-2gff-2mmg-m73p — Possible IP Spoofing in Action Dispatch RemoteIp, published November 4, 2026.

Get new posts in your inbox

One email whenever I publish something new. No spam, unsubscribe anytime.

← Back to all posts