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 readsX-Forwarded-For, so it isn’t affected by this particular bug). - Fixed in:
actionpack8.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 likeX-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 likeForwarded: 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:
- A reverse proxy (say, a plain Nginx config that doesn’t touch the
Forwardedheader at all) sits in front of a Rails 8.1 app. - An attacker sends a request directly, setting their own
Forwardedheader:Terminal window curl https://your-app.example.com/admin \-H "Forwarded: for=10.0.0.1" - Nginx passes the request through unchanged — it never set
X-Forwarded-Foritself in this scenario, so there’s nothing for Rails to cross-check theForwardedvalue against. - Rack 3’s
forwarded_forreads the attacker’sForwardedheader, sinceX-Forwarded-Foris absent. ActionDispatch::RemoteIptrusts that result asrequest.remote_ip— because the check only runs a comparison when both headers are present, and here only one is.- 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 endendBefore 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: :createendrate_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
# Gemfilegem "rails", ">= 8.1.4.1"# or, if you manage actionpack independently:gem "actionpack", ">= 8.1.4.1"bundle update actionpackThe 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 Forwardedhttp-request del-header X-Forwarded-Forhttp-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:
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:
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.