Talking to Browsers with CORS

Breaking the same-origin policy

Michael Krotscheck

TL/DR

Presentation
https://krotscheck.github.io/presentations/
CORS Specification
http://www.w3.org/TR/cors/
Example Implementation
http://git.openstack.org/cgit/openstack/
oslo.middleware/tree/oslo_middleware/cors.py#n176

Overview

  1. Same-origin policy & XMLHttpRequest
  2. Breaking the Browser Sandbox
  3. CORS Specification
  4. Example Implementation
  5. Demo

Same-origin policy

Netscape 2 (1995)

A user agent (browser) permits scripts contained in a first web page, to access data in a second web page, but only if both web pages have the same origin.

Origin

A combination of URI scheme, hostname, and port.

http://www.example.com:8080/script.js

  • http://www.example.com:8080/index.html
  • https://www.example.com:8080/index.html
  • http://example.com:8080/index.html
  • http://www.example.com:80/index.html

Why?


<script src="https://malicious.com/keystrokelogger.js" ></script>
<iframe src="https://amazon.com/all_my_credit_card_info.html"
  width="100%" height="100%" ></iframe>
          

Legacy Exceptions

Directly including scripts


          <script src="https://api.example.com/script.js" ></script>
        

Form POST


          <form method="POST" action="https://api.example.com/action" ></form>
        

XMLHttpRequest

"...that was the easiest excuse for shipping it so I needed to cram XML into the name." Alex Hopmann


Internet Explorer 5 (1999)
Outlook Web Access (Exchange Server 2000)

XMLHttpRequest


// http://myapp.example.com/scripts.js

var httpget = new XMLHttpRequest();
httpget.open("GET", "https://myapp.example.com/api/v1/thing", true);
httpget.send();

var httppost = new XMLHttpRequest();
httppost.open("POST", "https://myapp.example.com/api/v1/thing", true);
httppost.send("{}");
        

GET /api/v1/thing HTTP/1.1
Host: myapp.example.com
        

POST /api/v1/thing HTTP/1.1
Host: myapp.example.com
        

Except!

The same origin policy was extended to include XMLHttpRequest.


// http://myapp.example.com/scripts.js

// This doesn't work.
xmlhttprequest.open("GET", "https://api.example.com/v1/users/me.json");

// This doesn't work either
xmlhttprequest.open("GET", "https://api.square.com/billing/cards.json");
        

// https://mobile.example.com/scripts.js

// Nor does this.
xmlrequest.open("GET", "https://api.example.com/v1/users/me.json");
        

// https://tablet.example.com/scripts.js

// Or this.
xmlrequest.open("GET", "https://api.example.com/v1/users/me.json");
        

Hacks and Workarounds

The terrible things we did.

HTTP Proxies


// https://myapp.example.com/app.js
xmlhttprequest.open("GET", "https://myapp.example.com/square/billing/cards.json");
        

# pythonapp/proxy/square.py
import urllib2

def proxyGet(fragment):
  urllib2.urlopen('https://api.square.com/%s' % (fragment,)).read();
        


// Example response that would require an intelligent proxy.
[
  {'ref': 'https://api.square.com/billing/cards/1234.json',
   'type': 'link'},
  {'ref': 'https://api.square.com/billing/cards/2231.json',
   'type': 'link'}
]
        

document.domain

HTML DOM 2.0 (read only), January 2003


<!-- http://myapp.example.com/index.html -->
<iframe src="https://orders.example.com/iframe.html"></iframe>
<iframe src="https://products.example.com/iframe.html"></iframe>
        

document.domain = "example.com"

JSONP

JSON With Padding (2006)


<script type="application/javascript"
  src="https://api.square.com/billing/cards.js?callback=receiver">
</script>
        

receiver({"Name": "Foo", "Id": 1});
        

Cross-document messaging

https://www.whatwg.org (ca 2008)


// https://orders.example.com/childIframe.html
window.addEventListener('message', function receiver(event) {
  alert(event.data);
}, false);
        

// https://myapp.example.com/index.html
var childframe = document.getElementsByTagName('iframe')[0];
childframe.contentWindow
          .postMessage('message_content', 'https://myapp.example.com/');
        

WebSockets

RFC 6455 (2011)

Optionally, an |Origin| header field. This header field is sent by all browser clients.

Cross-Origin Resource Sharing (CORS)

January 2014

http//www.w3.org/TR/cors/

The two parts of CORS

  1. Preflight Check
  2. Request

CORS Preflight Exchange

Browser Request


OPTIONS /billing/cards HTTP/1.1
Host: api.example.com
Origin: https://myapp.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: accept, x-client
        

Permissive Response


HTTP/1.0 200 OK
Vary: Origin
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: accept,x-client
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
        

CORS Request

Browser Request


GET /billing/cards HTTP/1.1
Host: api.example.com
Origin: https://myapp.example.com
Accept: application/json
X-Client: myapp
        

Permissive Response


HTTP/1.0 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Accept,Content-Type,Cache-Control,Content-Language,Expires,Last-Modified,Pragma
        

CORS WildCard Response


HTTP/1.0 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Accept,Content-Type,Cache-Control,Content-Language,Expires,Last-Modified,Pragma
        

CORS: Server

  • Apache 2: mod_headers
  • Nginx: add_headers

CORS: Python Packages

  • django-cors-headers
  • wsgicors
  • Flask-Cors
  • tornado-cors
  • oslo_middleware

oslo_middleware

http://git.openstack.org/cgit/openstack/oslo.middleware/tree/oslo_middleware/cors.py


class CORS(base.Middleware):
    """CORS Middleware.

    This middleware allows a WSGI app to serve CORS headers for multiple
    configured domains.

    For more information, see http://www.w3.org/TR/cors/
    """

    simple_headers = [
        'Content-Type',
        'Cache-Control',
        'Content-Language',
        'Expires',
        'Last-Modified',
        'Pragma'
    ]

    def __init__(self, application, conf=None):
        super(CORS, self).__init__(application)

        # Begin constructing our configuration hash.
        self.allowed_origins = {}

    def add_origin(self, allowed_origin, allow_credentials=True,
                   expose_headers=None, max_age=None, allow_methods=None,
                   allow_headers=None):
        '''Add another origin to this filter.

        :param allowed_origin: Protocol, host, and port for the allowed origin.
        :param allow_credentials: Whether to permit credentials.
        :param expose_headers: A list of headers to expose.
        :param max_age: Maximum cache duration.
        :param allow_methods: List of HTTP methods to permit.
        :param allow_headers: List of HTTP headers to permit from the client.
        :return:
        '''

        if allowed_origin in self.allowed_origins:
            LOG.warn('Allowed origin [%s] already exists, skipping' % (
                allowed_origin,))
            return

        self.allowed_origins[allowed_origin] = {
            'allow_credentials': allow_credentials,
            'expose_headers': expose_headers,
            'max_age': max_age,
            'allow_methods': allow_methods,
            'allow_headers': allow_headers
        }

    def process_response(self, response, request=None):
        '''Check for CORS headers, and decorate if necessary.

        Perform two checks. First, if an OPTIONS request was issued, let the
        application handle it, and (if necessary) decorate the response with
        preflight headers. In this case, if a 404 is thrown by the underlying
        application (i.e. if the underlying application does not handle
        OPTIONS requests, the response code is overridden.

        In the case of all other requests, regular request headers are applied.
        '''

        # Sanity precheck: If we detect CORS headers provided by something in
        # in the middleware chain, assume that it knows better.
        if 'Access-Control-Allow-Origin' in response.headers:
            return response

        # Doublecheck for an OPTIONS request.
        if request.method == 'OPTIONS':
            return self._apply_cors_preflight_headers(request=request,
                                                      response=response)

        # Apply regular CORS headers.
        self._apply_cors_request_headers(request=request, response=response)

        # Finally, return the response.
        return response

    def _split_header_values(self, request, header_name):
        """Convert a comma-separated header value into a list of values."""
        values = []
        if header_name in request.headers:
            for value in request.headers[header_name].rsplit(','):
                value = value.strip()
                if value:
                    values.append(value)
        return values

    def _apply_cors_preflight_headers(self, request, response):
        """Handle CORS Preflight (Section 6.2)

        Given a request and a response, apply the CORS preflight headers
        appropriate for the request.
        """

        # If the response contains a 2XX code, we have to assume that the
        # underlying middleware's response content needs to be persisted.
        # Otherwise, create a new response.
        if 200 > response.status_code or response.status_code >= 300:
            response = webob.response.Response(status=webob.exc.HTTPOk.code)

        # Does the request have an origin header? (Section 6.2.1)
        if 'Origin' not in request.headers:
            return response

        # Is this origin registered? (Section 6.2.2)
        origin = request.headers['Origin']
        if origin not in self.allowed_origins:
            if '*' in self.allowed_origins:
                origin = '*'
            else:
                LOG.debug('CORS request from origin \'%s\' not permitted.'
                          % (origin,))
                return response
        cors_config = self.allowed_origins[origin]

        # If there's no request method, exit. (Section 6.2.3)
        if 'Access-Control-Request-Method' not in request.headers:
            LOG.debug('CORS request does not contain '
                      'Access-Control-Request-Method header.')
            return response
        request_method = request.headers['Access-Control-Request-Method']

        # Extract Request headers. If parsing fails, exit. (Section 6.2.4)
        try:
            request_headers = \
                self._split_header_values(request,
                                          'Access-Control-Request-Headers')
        except Exception:
            LOG.debug('Cannot parse request headers.')
            return response

        # Compare request method to permitted methods (Section 6.2.5)
        if request_method not in cors_config['allow_methods']:
            LOG.debug('Request method \'%s\' not in permitted list: %s'
                      % (request_method, cors_config['allow_methods']))
            return response

        # Compare request headers to permitted headers, case-insensitively.
        # (Section 6.2.6)
        for requested_header in request_headers:
            upper_header = requested_header.upper()
            permitted_headers = (cors_config['allow_headers'] +
                                 self.simple_headers)
            if upper_header not in (header.upper() for header in
                                    permitted_headers):
                LOG.debug('Request header \'%s\' not in permitted list: %s'
                          % (requested_header, permitted_headers))
                return response

        # Set the default origin permission headers. (Sections 6.2.7, 6.4)
        response.headers['Vary'] = 'Origin'
        response.headers['Access-Control-Allow-Origin'] = origin

        # Does this CORS configuration permit credentials? (Section 6.2.7)
        if cors_config['allow_credentials']:
            response.headers['Access-Control-Allow-Credentials'] = 'true'

        # Attach Access-Control-Max-Age if appropriate. (Section 6.2.8)
        if 'max_age' in cors_config and cors_config['max_age']:
            response.headers['Access-Control-Max-Age'] = \
                str(cors_config['max_age'])

        # Attach Access-Control-Allow-Methods. (Section 6.2.9)
        response.headers['Access-Control-Allow-Methods'] = request_method

        # Attach  Access-Control-Allow-Headers. (Section 6.2.10)
        if request_headers:
            response.headers['Access-Control-Allow-Headers'] = \
                ','.join(request_headers)

        return response

    def _apply_cors_request_headers(self, request, response):
        """Handle Basic CORS Request (Section 6.1)

        Given a request and a response, apply the CORS headers appropriate
        for the request to the response.
        """

        # Does the request have an origin header? (Section 6.1.1)
        if 'Origin' not in request.headers:
            return

        # Is this origin registered? (Section 6.1.2)
        origin = request.headers['Origin']
        if origin not in self.allowed_origins:
            LOG.debug('CORS request from origin \'%s\' not permitted.'
                      % (origin,))
            return
        cors_config = self.allowed_origins[origin]

        # Set the default origin permission headers. (Sections 6.1.3 & 6.4)
        response.headers['Vary'] = 'Origin'
        response.headers['Access-Control-Allow-Origin'] = origin

        # Does this CORS configuration permit credentials? (Section 6.1.3)
        if cors_config['allow_credentials']:
            response.headers['Access-Control-Allow-Credentials'] = 'true'

        # Attach the exposed headers and exit. (Section 6.1.4)
        if cors_config['expose_headers']:
            response.headers['Access-Control-Expose-Headers'] = \
                ','.join(cors_config['expose_headers'])

        

Demo

http://krotscheck.github.com/ironic-webclient

Questions?

Author:
Michael Krotscheck
krotscheck
http://www.krotscheck.net
Source:
https://github.com/krotscheck/presentations
License:
Creative Commons Attribution 4.0 International