Opened 6 months ago

Last modified 6 months ago

#27961 new Bug

Misconfigured SECURE_PROXY_SSL_HEADER setting is ignored

Reported by: cryptogun Owned by: nobody
Component: HTTP handling Version: 1.10
Severity: Normal Keywords: redirect HTTPS X-Forwarded-Proto
Cc: Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Tim Graham)

I'm using nginx + gunicorn and display pages via HTTPS:

  1. Both default settings:

Nginx setting: proxy_set_header X-Forwarded-Proto $scheme;
Django setting: SECURE_PROXY_SSL_HEADER = None
Result: No redirect. I'm not getting a ERR_TOO_MANY_REDIRECTS complain from Chrome.

  1. Use default setting in nginx; use a wrong setting in Django, i.e. the 'httpsssss' part:

proxy_set_header X-Forwarded-Proto $scheme;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'httpssssssss')
No redirect.

  1. Use default setting in nginx; use a wrong setting in Django:

proxy_set_header X-Forwarded-Proto $scheme;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTASDF', 'httpssssssss')
No redirect.

  1. Use custom HTTPS indicator in both nginx and Django:

proxy_set_header X-Forwarded-Protooo $scheme;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOOO', 'https')
No redirect. This is the expected behavior.

  1. Use custom HTTPS indicator in both nginx and Django, and testing for a unsafe protocol ( != 'https'):

proxy_set_header X-Forwarded-Protooo $scheme;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOOO', 'httpsssss')
Chrome complains ERR_TOO_MANY_REDIRECTS. This is the expected behavior.

  1. A fix testing by myself:

Add an else clause under these lines.

            else:
                return 'http'

And set:
proxy_set_header X-Forwarded-Proto $scheme;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'httpssssssss')
Chrome would report the expected ERR_TOO_MANY_REDIRECTS.

Did someone forget to add the else clause, or there are 3 'http' 'ftp' and 'ftps' scheme left?
If a site use 5. An attacker may set request X-Forwarded-Proto header to bypass the HTTPS check and result in 1,2,3.

Change History (7)

comment:1 Changed 6 months ago by Tim Graham

Component: contrib.redirectsHTTP handling

I'm having trouble understanding the report. What is the second line in each item (e.g. HTTP_X_FORWARDED_PROTO=None supposed to represent? Values of the SECURE_PROXY_SSL_HEADER setting? By the way, if there is a security issue here, please report the issue to the security team.

comment:2 Changed 6 months ago by cryptogun

Description: modified (diff)

comment:3 in reply to:  1 Changed 6 months ago by cryptogun

Replying to Tim Graham:

I'm having trouble understanding the report. What is the second line in each item (e.g. HTTP_X_FORWARDED_PROTO=None supposed to represent? Values of the SECURE_PROXY_SSL_HEADER setting? By the way, if there is a security issue here, please report the issue to the security team.

Yes your guess is right. Django sets HTTP_X_FORWARDED_PROTO to None by default as the link you provided tells. I've updated the main thread to make it more clear.
I think this is a small issue so not reported it to the security team. Since normally we do a HTTP to HTTPS redirect in nginx by this setting listen: 80; return 302 https://$server_name$request_uri;

comment:5 in reply to:  4 Changed 6 months ago by cryptogun

Description: modified (diff)

There are 2 bug cases:

  • In case 1, with no Django complain, user don't know whether his/her setting is correct and whether the HTTPS setup correctly.
  • In case 4, using a custom header by admin, a MITM may happen by:

user -- HTTP -- MITM(retrive password, set header: HTTP_X_FORWARDED_PROTO: https) -- nginx(header: HTTP_X_FORWARDED_PROTO: https, set header: HTTP_X_FORWARDED_PROOO: http) -- gunicorn(Pass, no user redirect because HTTP_X_FORWARDED_PROTO == https) -- Django(is_secure() == True because there's no else clause).

Last edited 6 months ago by Tim Graham (previous) (diff)

comment:6 Changed 6 months ago by Tim Graham

Description: modified (diff)

comment:7 Changed 6 months ago by cryptogun

Update. There is no security vulnerability here. The problem is: A misconfigured setting SECURE_PROXY_SSL_HEADER is ignored.
Explanation:

    @property
    def scheme(self):
        if settings.SECURE_PROXY_SSL_HEADER:
            try:
                header, value = settings.SECURE_PROXY_SSL_HEADER
            except ValueError:
                raise ImproperlyConfigured(
                    'The SECURE_PROXY_SSL_HEADER setting must be a tuple containing two values.'
                )
            if self.META.get(header) == value:
                return 'https'
        return self._get_scheme()

If self.META.get(header) != value, outer if ended with no return, so the last line return self._get_scheme() is executed. i.e.

  • A misconfigured setting SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'httpssssssss') is ignored without giving a warning and then fallback to self._get_scheme().
  • _get_scheme() is overridden to return self.environ.get('wsgi.url_scheme'). See here.
  • Gunicorn sets that environ. See here.
  • Gunicorn sets that environ by comparing request header to gunicorn setting secure_scheme_headers (default is {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'}). See here.

As for my 5 case:
Case 2 and 3 are misconfigurating SECURE_PROXY_SSL_HEADER in django. Scheme is determined by gunicorn without a warning.
Case 1, 4, 5: no problem, safe, works great.
2.
nginx sets header: proto (Abbr. of X-Forwarded-Proto header, you know what I mean.)
django gets scheme by checking this header: proto == httpssssssss (Abbr. of SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'httpssssssss'))
gunicorn gets scheme by checking these header: proto ssl protocol

result: HTTP: redirect. Then Chrome re-request via HTTPS. HTTPS: accept.
conclusion: Safe. Fallback to gunicorn.

3.
nginx: proto
django: protasdf == httpssssssss
gunicorn: proto ssl protocol

result: HTTP: redirect. HTTPS: accept.
conclusion: Safe. Fallback to gunicorn.

1.
nginx: proto
django: None
gunicorn: proto ssl protocol

result: HTTP: redirect. HTTPS: accept.
conclusion: Safe. Django let gunicorn to determine the scheme.

4.
nginx: protooo
gunicorn: proto ssl protocol
django: protooo == https

result: HTTP: redirect. HTTPS: accept.
conclusion: Safe. Django determines the scheme.

5.
nginx: protooo
gunicorn: proto ssl protocol
django: protooo == httpsssss

result: HTTP: redirect. HTTPS: redirect.
conclusion: Safe, redirect anything. Django determines the scheme. httpsssss != https, so HTTPS also redirected.

6.
nginx: proto
gunicorn: proto ssl protocol
django: proto == httpssssssss

result: HTTP: redirect. HTTPS: redirect.
conclusion: Safe. An else clause was added so the code won't fallback to gunicorn. Django determines the scheme, in contrast with case 2. httpssssssss != https, so HTTPS also redirected.

comment:8 Changed 6 months ago by Tim Graham

Summary: HTTP_X_FORWARDED_PROTO is bypassedMisconfigured SECURE_PROXY_SSL_HEADER setting is ignored
Triage Stage: UnreviewedAccepted

I have investigated in detail but the proposed change seems reasonable at first glance.

Note: See TracTickets for help on using tickets.
Back to Top