Flask apps on Heroku susceptible to IP spoofing

Posted: May 12, 2013

TL;DR — Flask apps deployed to Heroku or otherwise behind a proxy are often using unsafe code to get the user's "real" IP address. This allows malicious clients to spoof their IP address or replace it with any arbitrary string. (I'm not trying to pick on Flask or Heroku; it's surely a problem with many frameworks and most proxies -- Nginx & Apache included.)

The Problem

If you've ever deployed an application to Heroku or set up Varnish as a reverse caching proxy or used a "web accelerator" like CloudFlare or proxied Nginx to Gunicorn, then you've probably encountered a common annoyance: the REMOTE_ADDR variable becomes worthless since every connection looks like it's coming from your proxy server instead of the actual user making the request.

The Typical Solution

Luckily it's a common enough problem that there are many easy solutions. In Flask, you might use a snippet like the following from StackOverflow:

if not request.headers.getlist("X-Forwarded-For"):
   ip = request.remote_addr
   ip = request.headers.getlist("X-Forwarded-For")[0]

# (don't just copy/paste this -- keep reading)

Or perhaps the "fixer" than comes with werkzeug and is what the flask docs suggest. It does the same thing as the above, but also deals with X-Forwarded-Proto and X-Forwarded-Host.

from werkzeug.contrib.fixers import ProxyFix
# [ ... ]
app.wsgi_app = ProxyFix(app.wsgi_app)

# (don't just copy/paste this -- keep reading)

The above code seems to work great. I've created an extremely simple demo app that echoes your IP address and deployed it Heroku at http://flask-proxy-demo.herokuapp.com/ -- give it a spin, it looks like it works fine...

$ curl http://flask-proxy-demo.herokuapp.com/
Your IP address is

But here's where the problem comes in:

$ curl -H "X-Forwarded-For:" \
Your IP address is

Uh, oh. How about:

$ curl -H "X-Forwarded-For: <script>alert(1)" \
Your IP address is <script>alert(1)

Yikes. So what went wrong?

How X-Forwarding-For header works.

Since a request can potentially pass through multiple proxy servers, X-Forwarded-For is actually a comma-separated list. The first server adds the client IP to the header, the next server appends what it thinks the client IP is, and so on.

Imagine you've got CloudFlare running in front of Heroku so the request looks like
client --> cloudflare --> heroku --> app
That means the incoming request to your app will have a remote address coming from Heroku and a header that looks like
X-Forwarding-For: client.ip.addr, cloudflare.ip.addr

So grabbing the first address is tempting. But in this scenario, there's nothing stopping the client coming in with their own X-Forwarding-For header before their request hits CloudFlare (maybe they're malicious or maybe they just have their own proxy on their end). Either way, you get something like:
X-Forwarding-For: bogus.ip.addr, client.ip.addr, cloudflare.ip.addr

This may all be obvious if you work with proxies a lot, but it's easy to put the line proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; in your Nginx config without realizing that it appends to X-Forwarded-For rather than replacing it.

My Attempt at a Better Solution

Update: A variation of my fix described below has been committed to Werkzeug and should be available in the upcoming 0.9 release.

And then simply use it like ProxyFix:

from saferproxyfix import SaferProxyFix
app.wsgi_app = SaferProxyFix(app.wsgi_app)

This is a fix I hacked together. It's not perfect and I'd love to get some feedback for how it works in different scenarios, but I think it should be a lot safer than ProxyFix. It should work as a drop-in replacement for ProxyFix for the many apps that are behind a single proxy (like Heroku). If you're app is behind two proxies, simply pass in num_proxy_servers=2 and so on. Optionally, it adds a feature which you can enable by setting detect_misconfiguration=True that will raise an Exception to warn you if it appears you have incorrectly configured how many proxies you have.

Other Solutions

Django and Rails have both run into pretty much the exact same problem. Django "solved" it by removing the offending middleware. Rails went with a system that whitelisting proxies. They assume by default that you want to trust proxies running on the local network.

Discuss this entry via: Reddit | Twitter | Email