31

I've recently added a SSL certificate to my webapp. It's deployed on Amazon Web Services uses load balancers. The load balancers work as reverse proxies, handling external HTTPS and sending internal HTTP. So all traffic to my Flask app is HTTP, not HTTPS, despite being a secure connection.

Because the site was already online before the HTTPS migration, I used SSLify to send 301 PERMANENT REDIRECTS to HTTP connections. It works despite all connections being HTTP because the reverse proxy sets the X-Forwarded-Proto request header with the original protocol.

The problem

url_for doesn't care about X-Forwarded-Proto. It will use the my_flask_app.config['PREFERRED_URL_SCHEME'] when a scheme isn't available, but during a request a scheme is available. The HTTP scheme of the connection with the reverse proxy.

So when someone connects to https://example.com, it connects to the load balancer, which then connects to Flask using http://example.com. Flask sees the http and assumes the scheme is HTTP, not HTTPS as it originally was.

That isn't a problem in most url_for used in templates, but any url_for with _external=True will use http instead of https. Personally, I use _external=True for rel=canonical since I heard it was recommended practice. Besides that, using Flask.redirect will prepend non-_external urls with http://example.com, since the redirect header must be a fully qualified URL.

If you redirect on a form post for example, this is what would happen.

  1. Client posts https://example.com/form
  2. Server issues a 303 SEE OTHER to http://example.com/form-posted
  3. SSLify then issues a 301 PERMANENT REDIRECT to https://example.com/form-posted

Every redirect becomes 2 redirects because of SSLify.

Attempted solutions

Adding PREFERRED_URL_SCHEME config

https://stackoverflow.com/a/26636880/1660459

my_flask_app.config['PREFERRED_URL_SCHEME'] = 'https'

Doesn't work because there is a scheme during a request, and that one is used instead. See https://github.com/mitsuhiko/flask/issues/1129#issuecomment-51759359

Wrapping a middleware to mock HTTPS

https://stackoverflow.com/a/28247577/1660459

def _force_https(app):
    def wrapper(environ, start_response):
        environ['wsgi.url_scheme'] = 'https'
        return app(environ, start_response)
    return wrapper
app = Flask(...)
app = _force_https(app)

As is, this didn't work because I needed that app later. So I used wsgi_app instead.

def _force_https(wsgi_app):
    def wrapper(environ, start_response):
        environ['wsgi.url_scheme'] = 'https'
        return wsgi_app(environ, start_response)
    return wrapper
app = Flask(...)
app.wsgi_app = _force_https(app.wsgi_app)

Because wsgi_app is called before any app.before_request handlers, doing this makes SSLify think the app is already behind a secure request and then it won't do any HTTP-to-HTTPS redirects.

Patching url_for

(I can't even find where I got this one from)

from functools import partial
import Flask
Flask.url_for = partial(Flask.url_for, _scheme='https')

This could work, but Flask will give an error if you set _scheme but not _external. Since most of my app url_for are internal, it doesn't work at all.

Community
  • 1
  • 1
OdraEncoded
  • 3,064
  • 3
  • 20
  • 31

2 Answers2

28

I was having these same issues with `redirect(url_for('URL'))' behind an AWS Elastic Load Balancer recently & I solved it this using the werkzeug.contrib.fixers.ProxyFix call in my code. example:

from werkzeug.contrib.fixers import ProxyFix
app = Flask(__name__)

app.wsgi_app = ProxyFix(app.wsgi_app)

The ProxyFix(app.wsgi_app) adds HTTP proxy support to an application that was not designed with HTTP proxies in mind. It sets REMOTE_ADDR, HTTP_HOST from X-Forwarded headers.

Example:

from werkzeug.middleware.proxy_fix import ProxyFix
# App is behind one proxy that sets the -For and -Host headers.
app = ProxyFix(app, x_for=1, x_host=1)
punkdata
  • 885
  • 8
  • 15
  • 2
    This is **the** fix. Thank you so much! – Vedran Šego Aug 21 '17 at 18:12
  • 1
    This is a super hero. – Miron Oct 19 '18 at 14:20
  • 2
    Thanks a lot for the fix! Use it with AWS ELB and EC2 instances as targets – Dmitriy Feb 12 '19 at 18:18
  • 5
    Since werkzeug 0.15 (https://werkzeug.palletsprojects.com/en/0.15.x/middleware/proxy_fix/) it should be `from werkzeug.middleware.proxy_fix import ProxyFix; app = ProxyFix(app, x_for=1, x_host=1, x_proto=1, x_port=1)` - the class was moved to `midleware` package and it is neccessary to set `x_...` parameters to make it actually use the headers (x_host for X-Forwarded-Host and so on for other expected headers). – Borys Serebrov Mar 21 '19 at 01:16
  • 1
    According to AWS documentation ( https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html) only these headers are set: `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Port`. Do not set x_host as trusted. Also, the python path has changed to `from werkzeug.middleware.proxy_fix import ProxyFix`. Please update to for Flask apps: `app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_port=1)`. – Edward Corrigall Jun 08 '21 at 19:08
  • Since `X-Forwarded-Host` is not set by AWS ALB, then set manually using: `SERVER_NAME` to ensure that `url_for` correctly resolves. This may also require you to add an entry in `/etc/hosts`. – Edward Corrigall Jun 08 '21 at 19:16
8

Digging around Flask source code, I found out that url_for uses the Flask._request_ctx_stack.top.url_adapter when there is a request context.

The url_adapter.scheme defines the scheme used. To make the _scheme parameter work, url_for will swap the url_adapter.scheme temporarily and then set it back before the function returns.

(this behavior has been discussed on github as to whether it should be the previous value or PREFERRED_URL_SCHEME)

Basically, what I did was set url_adapter.scheme to https with a before_request handler. This way doesn't mess with the request itself, only with the thing generating the urls.

def _force_https():
    # my local dev is set on debug, but on AWS it's not (obviously)
    # I don't need HTTPS on local, change this to whatever condition you want.
    if not app.debug: 
        from flask import _request_ctx_stack
        if _request_ctx_stack is not None:
            reqctx = _request_ctx_stack.top
            reqctx.url_adapter.url_scheme = 'https'

app.before_request(_force_https)
OdraEncoded
  • 3,064
  • 3
  • 20
  • 31
  • For anyone coming here lately, this does not work with Flask 2.1 at least, and in 2.2 the `_request_ctx_stack` is going away. In my testing, the `Request` object is already instantiated with a `scheme` property by the time it is present in `before_request`, therefore you can change the `url_adapter` but it doesn't affect the request in progress. For the Flask changes I mentioned: See 2.2 docs https://flask.palletsprojects.com/en/2.2.x/reqcontext/#how-the-context-works vs 2.1 https://flask.palletsprojects.com/en/2.1.x/reqcontext/#how-the-context-works – mochatiger Jan 24 '23 at 16:27