First of all, what is CORS? It stands for Cross-Origin Resource Sharing, and if your eyes have already glazed over a little, don’t worry; it really isn’t that complicated. Say you have a site collection at https://intellipointsolutions.com, and it has some JavaScript that wants to load something from https://source.intellipointsolutions.com. The thing on https://source.intellipointsolutions.com is a cross-origin resource (or CORS), because https://intellipointsolutions.com is an origin and https://source.intellipointsolutions.com is a different origin. Now the origin is just the part of the URL up to the fully qualified host name (and port if a non-standard port is used), so https://intellipointsolutions.com/something and https://intellipointsolutions.com/somethingelse are NOT cross-origin resources, they both have the same origin of https://intellipointsolutions.com.
All modern browsers will prevent client-side code (i.e. JavaScript) from directly accessing cross-origin resources. This includes calling a web service cross-origin. It also includes dynamically changing the src property of an iframe to a cross-origin resource. Note that sometimes people refer to the mechanism for accessing cross-origin resources as cross-document messaging or cross-domain messaging. As previously stated, https://intellipointsolutions.com/something and https://intellipointsolutions.com/somethingelse are not cross-origin but they are cross-document. And https://intellipointsolutions.com and https://source.intellipointsolutions.com are in the same domain (i.e. not cross-domain) but they are cross-origin. So I’ll stick with cross-origin messaging since it is more precisely accurate and matches the CORS acronym.
Now, why should you care? Because Microsoft in its infinite wisdom has decided that all site collections should be Host Named Site Collections (HNSCs) and many organizations will follow that advice as they migrate to SharePoint 2016 and beyond. That means if your office currently has two site collections with URLs like https://intellipointsolutions.com/sites/A and https://intellipointsolutions.com/sites/B, then during migration your URLs are going to change to https://A.intellipointsolutions.com and https://B.intellipointsolutions.com. So first, you’ll need to update any links or references from A to B and vice versa, which is a bit of a PITA. But also, A and B will now and forever more be cross-origin resources. If you have any client-side code in A that directly access resources in B, that code will stop working.
And finally, why does Microsoft hate me? Well, they don’t. It’s actually a pretty scary problem, that isn’t easy to address, but if you look at each site collection as a security boundary (which has always been best practice) and you put all site collections in separate origins (HNSCs) then that closes this vulnerability by default. And while there are legitimate reasons to want to share some resources across origins, there are techniques that will allow this that are safer than direct access. Safer because something has to be done in each origin to allow that access, and that something can enforce restrictions on who can access what and from where.
Ok, really finally, why is cross-origin resource sharing so potentially scary. Because the attack vector is so easy to exploit. It requires neither a great deal of access nor a great level of technical expertise. Take the following scenario:
Users Mary and Bob both have Contribute access to /sites/A. Only Mary has access to /sites/B (also contribute).
If Bob wants access to something in /sites/B, all he has to do is:
- Insert some JavaScript into the home page of /sites/A that tries to download some stuff from /sites/B. If it fails it should fail silently. If it succeeds it will copy that stuff to a list or library on /sites/A.
- Wait for Mary to come to the home page on /sites/A (or anyone else who has access to /sites/B).
Once Mary goes to the home page, Bob has information waiting for him in a list or library on /sites/A that he’s not supposed to have access to. And it isn’t just read. Since Mary is a contributor on /sites/B, this technique can just as easily allow Bob to write to or delete from /sites/B.
Granted not all SharePoint users can even write a little JavaScript, but if you’re at a large organization, there are probably at least hundreds of people with the skills to pull off this very simple exploit. And it doesn’t require administrative access, or any extraordinary level of access, to do it; just contribute (or read, if all you want to do is access data without modifying it). That’s…not great. And if you’re thinking Bob and Mary work for the same organization, why would Bob do that, I’d encourage you to do a little light reading on the topic of insider threat.
Cross-Origin Messaging to the Rescue
Anyway, all modern browsers now support Cross-Origin Messaging through the method window.postMessage(message, origin). I’ve seen some pretty complicated explanations of postMessage, and it gets more complicated with inconsistencies in browser implementations, but it’s really not that complex and browser inconsistencies are less than they used to be.
Below is a very simple ASPX page (well, part of a SharePoint Wiki page) that uses jQuery to hook a postMessage handler, and upon receiving a message it pops up an alert with the message and posts a message back to its parent page (it assumes it is running in an iframe). You now have at your disposal everything you need to know to use postMessage…well, almost.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server"> <h1>Proxy Test Page</h1> <script type="text/javascript"> // add an event handler for postMessages received $(window).on("message", function(e) { alert(e.originalEvent.data); // send a message back to the parent frame (which is expected // to be https://intellipointsolutions.com) postMessage( "Hello back at ya' from the iframe!!!", "https://intellipointsolutions.com"); }); </script> </aps:Content> |
And here is the very simple client for this proxy. It resides in the site https://intellipointsolutions.com and loads the proxy page in an iframe from the site https://source.intellipointsolutions.com.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server"> <h1>Client Test Page</h1> <iframe id="spproxy" height="0" width="0" src="https://source.intellipointsolutions.com/Style Library/proxy.aspx"> </iframe> <script type="text/javascript"> // add an event handler for postMessages received $(window).on("message", function(e) { alert(e.originalEvent.data); }); setTimeout(function() { $("spproxy")[0].contentWindow.postMessage( "Hello from parent window!", "https://source.intellipointsolutions.com"); }, 300); </script> </aps:Content> |
So when a user goes to https://intellipointsolutions.com/Style Library/client.aspx, it loads proxy.aspx from the Style Library on https://source.intellipointsolutions.com. It then uses jQuery to set up a handler for postMessage (and pops up a message in an alert if it gets one), and finally, it waits 300 milliseconds (via setTimeout) and sends a postMessage to the iframe. The proxy in the iframe pops up a dialog with the message and sends a postMessage back to the parent frame. The user will see the “Hello from parent frame!” alert (ironically popped up by the child frame) and then the “Hello back at ya’ from the iframe!!!” alert (popped up from the parent frame). A few thoughts about this code in no particular order:
- The code is passing a simple string as a message. According to the spec, it could pass a POJO (plain old JavaScript object) and the browser should serialize it for you. In reality, browser implementations vary, and in Internet Explorer (v11) Microsoft does serialize the object but using toString instead of JSON.stringify like every other browser, which produces the decidedly poor result of losing all of your data. As that probably isn’t what you want, if you need to work cross-browser, you should serialize/de-serialize objects yourself as I will in my remaining sample code.
- Now is this really more secure. It is and it isn’t:
- It is because, in order for this to work, the source site needs to do something to allow you to access it, so the data owner has some say as to who can access what and from where.
- And it is because the data is serialized in transit.
- But it isn’t if the consuming code treats the message as coming from a completely trusted source and does something stupid like evals it or shoves it directly into the HTML without some sort of escaping. At that point, the client is just as vulnerable to cross-site scripting attacks as before. Sure, ideally you’ve verified the DNS name of the origin, but cross site scripting attacks generally involve some sort of hijacking technique like DNS spoofing, so that doesn’t buy you a great deal of security. In general, don’t treat the message as fully trusted and you’ll be fine.
- Why does the code use setTimeout to delay 300 milliseconds before sending the message. Because if it sends the message before the iframe is listening for it, the message is lost. How long should it wait? Who knows? It depends on how long it takes the browser to render the iframe and execute the JavaScript within it. If you need to send something “right away” like this, you probably need something more reliable than setTimeout for a blindly guessed interval, like maybe attach an event handler to the iframe waiting for the load event.
- postMessage takes two arguments, the message and the expected origin. You can only set the origin to a single address or use “*” to say I’ll accept messages from anywhere. Note that while “*” is often referred to as a wildcard, the asterisk is not a general purpose wildcard, so you can’t do something like “https://*.intellipointsolutions.com” and expect it to match anything. The end result of this is, if you want to accept messages from more than one origin, you can only accept messages from all origins. This isn’t ideal, as it weakens the security posture of postMessage nearly back to the wild west days of direct access across all origins, but it is what it is, and there are ways to mitigate that in your code, which I’ll save for a later post.
- Finally, SharePoint pages do not normally allow you to load them in an iframe. In order to allow the proxy page to be loaded in an iframe, you need to add:123<%@ Register Tagprefix="SharePoint"Namespace="Microsoft.SharePoint.WebControls"Assembly="Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
at the top of the page, if it’s not already there, and you need to add:1<WebPartPages:AllowFraming runat="server"></WebPartPages:AllowFraming>
in the head of your document.
So like I said, pretty much a piece of cake. Or not, but it isn’t that complicated either. And if you’re thinking “big whup,” all I’ve done is sent a string from point A to point B, the point is that CORS doesn’t get more complicated when passing complex objects back and forth, other than the need to call JSON.stringify and JSON.parse to serialize/de-serialize (and you wouldn’t even need that if browser implementations were more consistent). So to postMessage with say Ajax requests and responses, you just need to use your imagination a little. Here is a simple jQuery web service call to retrieve values from an Announcments list using promises:
1 2 3 4 5 6 7 8 9 10 11 12 | $.ajax({ url: "~site/_api/lists/getbytitle('Announcements')", type: "GET", contentType: "application/json;odata=verbose", headers: { "Alert": "application/json;odata=verbose" } }).then(function(data) { alert(JSON.stringify(data)); }).fail(function(data) { alert(JSON.stringify(data)); }); |
Now all of that gobbledygook between the curly braces are just the options passed to $.ajax to invoke some REST service call. So what if you passed that as a postMessage, and the receiver used it to invoke $.ajax and send back the response as a postMessage. Now you’ve got real web service calls working across cross-origin site collections, and it’s more secure for all the reasons stated above, at least it will be if you code it correctly. The rest of this post will show a simple implementation that does just that.
The CORS Service Proxy
This is the code that sits on the source and will accept Ajax options as a message. It will send those options to a SharePoint REST endpoint and send the response back to the parent window as a postMessage (it assumes it is in an iframe).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | <asp:Content ContentPlaceHolderID='PlaceHolderMain' runat='server'> <h1>Service Proxy Test Page</h1> <!-- button to test locally --> <input name="btnTest" id="btnTest" type="button" value="Test Proxy Locally"> <br> <script type="text/javascript"> // change these to match your environment var expectedOrigin = "https://intellipointsolutions.com"; var listTitle = "Announcements"; // add an event handler for postMessages received $(window).on("message", function (e) { try { // parse the json data and use the resulting object as input to $.ajax callRestApi(JSON.parse(e.originalEvent.data)); } catch (e) { alert(e); } }); // call the REST end point function callRestApi(options) { // substitute the site url for ~site options.url = options.url.replace( /~site/g, _spPageContextInfo.webAbsoluteUrl); // add a request digest, the client can't do this because the digest // is site collection specific, we also don't really need this for a // GET request, but would for a POST etc. options.headers["X-RequestDigest"] = $("#__REQUESTDIGEST").val(); // hook up success and error callbacks options.success = function (data) { sendResponse(options.url, "ok", data); } options.error = function (data) { sendResponse(options.url, "error", data); } // call $.ajax $.ajax(options); } // send the web service response or error as a postMessage function sendResponse(url, status, data) { // construct a response object, the web service response is in the message property var response = { url: url, status: status, message: data }; if (window = window.top) { // we're not in a iframe, just testing locally alert(JSON.stringify(response, null, 4)); } else { // otherwise, post the response to the parent frame parent.postMessage( JSON.stringify(response), expectedOrigin); } } $("#btnTest").click(function () { // call the web service locally, this is easier to debug and make sure you // have the Ajax options right before adding the complexity of CORS callRestApi({ url: "~site/_api/lists/getbytitle('Announcements')", type: "GET", contentType: "application/json;odata=verbose", headers: { "Accept": "application/json;odata=verbose" } }); }); </script> </asp:Content> |
Also, don’t forget to add the SharePoint:AllowFraming control described above to the PlaceHolderAdditionalPageHead content area. Or you can save yourself a bit of typing and download the source:
- Download the CORS_Source.zip file and extract it somewhere.
- Open the proxy.aspx file and replace the expectedOrigin and listTitle with something that makes sense for your environment (I’ve highlighted the lines you need to modify in the source above).
- Go to the site that’s going to be the source.
- Upload your modified copy of proxy.aspx to the Style Library.
Now click on proxy.aspx in the Style Library and you should see something like:
There may be differences, like if you published you won’t see the yellow warnings, and of course your theme may be different, but you should have a header that says Service Proxy Test Page and a button below it that says Test Proxy Locally.
Click on the Test Proxy Locally button and you should see something like:
What matters is what does the response look like, and pay particular attention to the message property. It should have a property named d which should be an object with a property named __metadata, etc. etc. In other words, it should look like the json response from a SharePoint web service call with odata set to verbose. If it doesn’t, it could be an error or it could be a page, like a 404 not found page. And if the dialog doesn’t pop up, you’ve probably missed a step or configured something incorrectly. It’s almost certainly one of the two lines I highlighted in the source above, unless you’re coloring outside the lines (i.e. you changed something in the source I didn’t tell you to change). Either way, launch developer tools to debug and look for console errors and step through it. Don’t bother trying to test the client page cross-origin until you get this local test to work.
If you got it working, fantastic, but so far we’ve only tested that the web service call works locally. We’ll test calling it cross-origin in the next section.
The CORS Proxy Client
This is the code that sits on the site collection that will be the consumer of the CORS REST request. It will load the Service Proxy code in an iframe from the source site collection, send it messages via postMessage containing Ajax options for a REST service request, and process the response via a postMessage handler.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | <asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server"> <h1>Proxy Client Test Page</h1> <!-- button to test the service call through CORS --> <input type="button" name="btnTest" id="btnTest" value="Test CORS Proxy"/><br/> <!-- iframe to load the service proxy --> <iframe id="spproxy" src="https://source.intellipointsolutions.com/Style Library/proxy.aspx" height="0"> </iframe> <script type="text/javascript"> // change these to match your environment var expectedOrigin = "https://intellipointsolutions.com"; var listTitle = "Announcements"; // add an event handler for postMessages received $(window).on("message", function(e) { try { // parse the json result and popup in an alert alert(JSON.parse(e.originalEvent.data, null, 4)); catch(e) { alert(JSON.parse(e, null, 4)); } }); // send a message to the iframe with the Ajax options function callRestApiThroughPostMessage(options) { $("spproxy")[0].contentWindow.postMessage(JSON.stringify({ url: "~site/_api/lists/getbytitle('Announcements')", type: "GET", contentType: "application/json;odata=verbose", headers: { "Alert": "application/json;odata=verbose" } }, expectedOrigin); } </script> </aps:Content> |
To get this working from the download:
- Download the CORS_Source.zip file and extract it somewhere.
- Open the client.aspx file and replace the expectedOrigin, listTitle, and iframe source with something that makes sense for your environment (I’ve highlighted the lines you need to modify in the source above).
- Go to the site that’s going to be the proxy consumer.
- Upload your modified copy of client.aspx to the Style Library.
Now click on client.aspx in the Style Library and you should see something like:
Click on the Test CORS Proxy button and you should see something like:
If the dialog doesn’t pop up, again you’ve probably missed a step or configured something incorrectly, probably one of the highlighted lines in the above source. Diagnostics aren’t that much different from for the local test, but now you may need to look at two frames, the parent and the iframe. Launch developer tools to debug and look for console errors. Note that you may see the error message below, during page load:
This may look like cross-origin messaging isn’t working, but keep in mind that we don’t try to do anything on page load. Our action starts with the button click. So this message isn’t related to our code, and can be ignored. This is actually in SharePoint’s init.js, and it is apparently trying to do something inappropriate to our cross-origin frame during startup, but it doesn’t prevent our code from working. So this message actually indicates that Cross-origin Resource Sharing (CORS) is working as designed; bad SharePoint! Stay outa’ my iframe. I’ve only seen this error on SharePoint 2016 so far.
If you got it working, congratulations! You’ve just successfully called a web service cross-origin using CORS. And if you want to call a different web service in the source site, you just need to modify the Ajax parameters in client.aspx. The code in proxy.aspx probably won’t need to change at all.
Wrapping it Up
So I’ve said it before and I’ll say it again, not rocket science. On the other hand, processing all response through the postMessage handler doesn’t ‘feel’ great, and if you’re processing lots of different service calls on a single page, that message handler can start to look like an old Windows Message Loop, with a big switch/case statement that hands off processing to one of a bunch of different helper methods based on some criteria in the response.
On the other hand, with a little bit of a wrapper around postMessage, you can make it such that the consumer does just pass in success/error callbacks, or even gets back a promise to work with, and calling web services through CORS starts to look a lot like what we’re used to. This post has already gotten pretty long, so I’ll do that in my next post. That may be in a couple of weeks, so check back if you’re interested.
CORS_Source.zip (the complete source code)