For a Rails app served through Cloudflare: refuses requests that did not come through Cloudflare, and sets the client IP from CF-Connecting-IP on those that did.
Behind Cloudflare and a proxy server in front of the app, request.remote_ip tends to return the proxy's own address for every request, because the proxy appends itself to X-Forwarded-For and Rails does not trust it. Trusting the proxy and Cloudflare's IP ranges instead means a list to keep current. This gem takes a different route: once a request has proved it came through Cloudflare, the address Cloudflare put in CF-Connecting-IP is the visitor.
gem "rails-cloudflare-origin"A Railtie installs the middleware at the top of the stack, above anything the app inserts with insert_before 0 from an initializer or an environment file, and above Rails' own HostAuthorization and ActionDispatch::SSL. Nothing else is needed in the app.
- Removes
Forwarded,Client-IP,X-Forwarded-For,X-Real-IPandX-Forwarded-Host. A visitor can send any of these, and conflicting values make Rails raiseActionDispatch::RemoteIp::IpSpoofAttackError, which surfaces as a 500.X-Forwarded-ProtoandX-Forwarded-Portstay. - Takes the
X-Origin-Secretheader out of the request, so it never reaches the app, its logs, or an error tracker. - Lets a
GETorHEADof exactly/upthrough without the secret when it carries noCF-Rayheader. This is the platform's health check, which reaches the app directly instead of through Cloudflare. A/upthat came through Cloudflare still needs the secret. - Answers anything else without a matching secret with a
403andcache-control: no-store. - Sets
REMOTE_ADDRtoCF-Connecting-IP, sorequest.ipandrequest.remote_ipboth return the visitor. A request that carries the secret but noCF-Connecting-IP, or one that is not an IP address, raisesCloudflareOrigin::InvalidClientIpError. Cloudflare always sends that header, so its absence means a zone setting changed, and an error is easier to notice than every visitor quietly sharing one address. The middleware runs above Rails' own error handling, so it reports the error toRails.erroritself before raising: any error tracker subscribed toRails.errorreceives it, and the request ends as a 500 from the app server.
- The origin secret. A request carrying it came through your Cloudflare zone, because only the zone adds it.
CF-Connecting-IPon such a request. Cloudflare refuses a request in which the visitor sent that header themselves (Error 1000), so on a request that passed through the zone, the value is Cloudflare's.
- A Request Header Transform Rule on the zone sets
X-Origin-Secretto the secret the app holds, on every request to the app's hostnames. Generate the value withopenssl rand -hex 32. - The "Remove visitor IP headers" managed transform is off, and no other Transform Rule or Worker removes or rewrites
CF-Connecting-IP. - Leave Pseudo IPv4 off: in Cloudflare's "overwrite headers" mode an IPv6 visitor's address is replaced with a made-up IPv4 address from
240.0.0.0/4, and the gem records that address.
The gem reads two secrets, each from an environment variable first and from the app's credentials second:
| Secret | Environment variable | Credential |
|---|---|---|
| Current, required to switch the gem on | CLOUDFLARE_ORIGIN_SECRET |
cloudflare_origin_secret |
| Previous, being rotated out, optional | CLOUDFLARE_ORIGIN_SECRET_PREVIOUS |
cloudflare_origin_secret_previous |
Each secret is resolved on its own: a non-blank environment variable wins over the credential. A variable left set on the host therefore overrides the credential without any warning, so a stale one keeps an old value in force after you rotate the credential.
Both secrets are accepted, so the value rotates without an outage: set the new value as the current secret and the old one as the previous secret, deploy, switch the Transform Rule to the new value, then remove the previous one.
A secret that is present but shorter than 32 characters fails the boot, so a broken value stops a deploy instead of answering every request with a 403. A blank secret counts as absent.
The middleware is installed only when a current secret is present, and never in the development and test environments, even when a secret is set, so local requests and specs see the headers they send.
When no current secret is present, the gem installs nothing. Outside development and test it logs a warning at boot saying the middleware is not installed. An asset build that boots without credentials has no secret, so it is simply off.
Ruby 4.0 or newer and Rails 8.1 or newer. The gem targets the latest Ruby and Rails only, and these floors move up with each release.
bundle install
bundle exec rake test
bundle exec standardrbMIT. See LICENSE.