In this post I’m going to demonstrate a CORS Wrapper for postMessage operations, specifically in SharePoint, and intended to make CORS operations as simple as the Ajax operations we’re more familiar with. I’m going to develop the same simple pages I used in my last post, only using the CORS Wrapper this time. Then I’ll dump the CORS Wrapper on you. I’m not going to talk a great deal about the code, I’ve included a ridiculous number of comments in the code to explain what I’m doing.
Anyway, in my last post, I described the basics of using postMessage to do cross-origin web service calls in SharePoint. And I stressed that it is not that complicated. In just under 20 lines of JavaScript, I was able to expose the complete range of web services on a site collection to another site collection. And with another 10 lines of JavaScript I was able to consume one of these web services on another site collection.
And yet, in my experience, a lot of developers think this is too complicated and don’t want to deal with it. I think the reasons for this are twofold:
- SharePoint and it’s web services are already complicated. First, there’s a bunch of them, and they all take different parameters. And you need to set different headers depending on what you’re doing. And is it a GET, or POST, or MERGE. And they’re not very well documented, although that’s getting better. There are plenty of simple examples, but few complex ones (for instance, a lot is left to the imagination when it comes to filters or how lazy loading works).
- While postMessage does not add a ton of complexity, adding any complexity at all makes developers groan in agony (mostly because of reason 1).
Looks like an opportunity for some sort of CORS Wrapper or library. Deal with the complexity once, and forever more use the library to hide most if not all of the additional complexity.
The New CORS Wrapper Service Proxy Page
Below is the new Service Proxy page. Like before, it includes a button to test the web service calls locally, but is primarily intended to be loaded in a hidden iframe and called cross-origin. Don’t forget that you also need to include in the page a SharePoint:AllowFraming control or SharePoint won’t let you load the page in an iframe.
The CORS wrapper/library is in spcors.js in the same directory as the page. To implement the service proxy page, all you really need to do is instantiate an object like so:
1 | var serviceProxy = new SPCORS.ServiceProxy("*"); |
This creates an event handler for post messages which takes in ajax options as a message, calls $.ajax, and sends a message back to the parent window.
Below is the complete JavaScript source code for the page. Like above, it initializes the origin to “*”, which isn’t ideal, but is your only option if you want to accept requests from multiple cross-origins.
But the ServiceProxy also has two additional parameters; originPatterns and urlPatterns. Both of these are arrays of RegExp instances. If either parameter is initialed with a non-zero-length array of patterns, it will validate that something matches at least one of the patterns. Not to surprisingly, the something that is compared is the origin against originPatterns and the ajax URL against urlPatterns.
These two options provide a great deal of flexibility to lock down who can call what and from where. Ideally you should always put some restrictions on both. As in don’t allow calls from anywhere. And don’t allow calls to anything. This is especially true if you have multiple origins that need access so you specified “*” as the expected origin.
Now if none of your data is important to anyone, go ahead in fire up the ServiceProxy with a single argument of “*” like above and have at it. But I’m a consultant and to date I’ve never had a client tell me that none of their data is important or sensitive.
And without further ado, here is the code for the proxy page:
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 | <asp:content contentplaceholderid="PlaceHolderMain" runat="server"> <h1>ServiceProxy Test Page</h1> <script type="text/javascript" src="https://source.intellipointsolutions.com/Style Library/spcors.js"></script> <input name="btnTest" id="btnTest" type="button" value="Test Local"> <script type="text/javascript"> (function() { // a list of patterns to match against the origin var originPatterns = [ new RegExp("^https://intellipointsol\.com$"), new RegExp("^https://intellipointsoltest\.com") ]; // a list of patterns to match against the ajax url var urlPatterns = [ new RegExp( "^https://source\.intellipointsol\.com/_api/lists/getbytitle.'Announcements'.", "i"), new RegExp( "^https://source\.intellipointsol\.com/_api/lists/getbytitle.'Announcements'.", "i") ]; // Create a new instance of ServiceProxy. Ideally, you should always // provide a specific expected origin, but if you want to allow // multiple sites, "*" (i.e. all origins) is your only option. If // originPatterns is defined and not empty, the // origin must match at lease one of these patterns. If urlPatterns // is defined and not empty, the url passed in the ajax options must // match at lease one of these patterns. These options provide a // lot of flexability in locking down who can call what from where. // These are enforced in JavaScript in the ServiceProxy class. var sp = new SPCORS.ServiceProxy("*", originPatterns, urlPatterns); // note: technically we're done. Just create the service proxy and // it is now listening for messages, which it will try to pass as // options to $.ajax, and send the response back as a message, // assuming all security rules pass. // This is just a local (non-cross-origin) tester of the // serviceProxy.ajax. It's easier to debug the ajax calls locally and // get them right before adding the complexity of calling them // cross-origin. document.getElementById("btnTest").addEventListener("click", function() { sp.ajax({ url: "~site/_api/lists/getbytitle('Announcements')", type: "GET", contentType: "application/json;odata=verbose", headers: { "Accept": "application/json;odata=verbose" }, success: function(data) { alert(JSON.stringify(data, null, 4)); }, error: function(error) { alert(JSON.stringify(error, null, 4)); } }); }, false); })(); </script> </asp:content> |
Not bad. A few lines of code, a whole bunch of comments, and a few lines of test code, and we’ve exposed as much of the SharePoint REST API as we want to in a reasonably secure manner.
As before, this page is intended to be loaded in an iframe, but if you go to the page directly in the browser, and click the button, you can test calling the web service directly and make sure you’ve got that right before adding the complexity of trying to call the service cross-origin.
The New CORS Wrapper Proxy Client Page
And here is the proxy client page. I’ve been a bit naughty here and assigned my ProxyClient instance to a local variable called $. Not that anyone can really claim to have exclusive rights to $, but so many people are used to that being jQuery that it’s arguable not a good idea to use it for something else. Anyway, I did it to show that my call to $.ajax (highlighted below) is virtually identical to what you would expect calling the jQuery function with that name.
Also, keep in mind that to look at this as if one side is the server and the other the client is a dangerous falicy. Both frames have a message handler and recieve messages, so both sides are the server. And both sides send messages to other, so both are the client/consumer. So security needs to be implemented on the Proxy Client page too, thus the originPatterns array here too. The urlPatterns here don’t make any sense, since we’re recieving ajax responses, which don’t necessarily have a url.
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 | <asp:content contentplaceholderid="PlaceHolderMain" runat="server"> <h1>ProxyClient Test Page</h1> <input name="btnTest" id="btnTest" type="button" value="Test Local"> <script type="text/javascript" src="https://source.intellipointsolutions.com/Style Library/spcors.js"></script> <iframe src="https://source.intellipointsolutions.com/Style Library/proxy.aspx" id="spproxy" height="0" width="0"></iframe> <script type="text/javascript"> (function() { // a list of patterns to match against the origin var originPatterns = [ new RegExp("^https://source\.intellipointsol\.com$"), new RegExp("^https://source\.intellipointsoltest\.com") ]; // not using jQuery, so safe to assign $ in my local scope // (just to show how similar usage looks to jQuery) var $ = new SPCORS.ProxyClient("spproxy", "*", originPatterns); // if you need to support IE < 9, you need to check for existence // of attach event and use it instead document.getElementById("btnTest").addEventListener("click", function() { $.ajax({ url: "~site/_api/lists/getbytitle('Announcements')", type: "GET", contentType: "application/json;odata=verbose", headers: { "Accept": "application/json;odata=verbose" } }).then(function(data) { alert(JSON.stringify(data, null, 4)); }).fail(function(error, data) { alert(JSON.stringify(data, null, 4)); }); }, false); })(); </script> </asp:content> |
The Post Message CORS Wrapper Library
Below is the implementation of the two JavaScript classes I’m using above. You now have a pretty good idea of how to use them, and there are ample comments explaining what they’re doing under the hood, so there really isn’t that much to say. Heck, if you strip out the comments you’ll see that it’s actually a pretty trivial amount of code.
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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | (function() { // define namespace window.SPCORS = window.SPCORS || {}; // helper returns true if str matches any RegExp in arr var matches = function(str, arr) { return !arr || (str && arr.reduce(function(acc, url) { return acc || url.test(str); }, false)); }; // helper throws exception if str does not match any RegExp in arr var gaurde = function(str, arr, type) { if(!matches(str, arr)) { throw new Error(type + ": " + str + " does not match [" + arr.join() + "]."); } }; // helper converts a url to an origin var baseUrl = function(url) { var parts = url.split("/"); return parts.length > 3 ? parts.splice(0, 3).join("/") : url; }; /** * Represents a prox that listens for postMessage events and attempts to * convert them to SharePoint ajax requests, send the reponse back as a * postMessage to the parent window (assumes running in an iframe). * @constructor * @param (string) origin - The url expected for the response origin, will * accept any origin if set to "*". * @param [string] originPatterns - message handler will post an exception * message if this array is defined and contains at least one RegExp and the * origin does not match any of these patterns. * @param [string] urlPatterns - ajax will post an exception message if this * array is defined and contains at least one RegExp and the * ajax url does not match any of these patterns. * @returns {object} a new ServiceProxy instance. */ function ServiceProxy(origin, originPatterns, urlPatterns) { var _self = this; /** * Pass the options on the $.ajax. Options can be anything used to call * $.ajax, except it cannot contain callbacks, since we're expecting * cross-origin calls and will use postMessage to send the response. * @param {object} - See jQuery.ajax documentation for a description * of options. * @return (undefined) will call respond to send the response as a * post message. */ this.ajax = function(options) { try { // replace 'magic' strings in the url options.url = options.url.replace( /~site/g, _spPageContextInfo.webAbsoluteUrl); options.url = options.url.replace( /~sitecollection/g, _spPageContextInfo.siteAbsoluteUrl); if (window !== window.top) { // throw exception if urlPatterns test fails gaurde(options.url, urlPatterns, "URL"); } // this has to gotten in the service proxy since the digest // is site collection specific and we're cross-origin options.headers["X-RequestDigest"] = document.getElementById("__REQUESTDIGEST").value; // add success callback to options options.success = function(data) { delete options.success; // before serialize response delete options.error; // before serialize response // send web service response as post message _self.respond(options, "ok", data); }; // add error callback to options options.error = function(data) { delete options.success; // before serialize response delete options.error; // before serialize response // send web service response as post message _self.respond(options, "error", data); }; // call $.ajax with the options $.ajax(options); } catch(e) { _self.respond(options, "exception", e.message); } return; }; /** * Send an ajax response (or exception) back to the calling frame via * post message. * @param {options} - The options passed to $.ajax. * @param (string) - A status message included in the response, generally * either 'ok' or some kind of error. * @param {data} the data returned from the web service, or a string * exception message. * @return (undefined) will call respond to send the response as a * post message. */ this.respond = function(options, status, data) { if(data) { var response = { options: options, status: status, message: data }; if (window !== window.top) { parent.postMessage(JSON.stringify(response), origin); } else { alert(JSON.stringify(response, null, 4)); } } return; }; // Create a listener for postMessage events and pass the data to $.ajax window.addEventListener("message", function(e) { if(typeof(e.data) === "string") { var message; try { // deserialize the json data message = JSON.parse(e.data); } catch(e) { /* assume message for someone else */ } // if not json, then not a message for this handler if(message) { // throw an exception if originPatterns do not match origin gaurde(e.origin, originPatterns, "Origin"); // pass the message to this.ajax _self.ajax(message); } } }, false); } // attach class definition to namespace SPCORS.ServiceProxy = ServiceProxy; /** * Represents a consumer of SharePoint ajax requests using postMessage to * execute requests safely in another domain (i.e. a consumer of postMessages * from ServiceProxy). * @constructor * @param (string) iframeid - the id of the iframe DOM element that implements * the ServiceProxy. * @param (string) origin - The url expected for the response origin, will * accept any origin if set to "*". * @param [string] originPatterns - message handler will post an exception * message if this array is defined and contains at least one RegExp and the * origin does not match any of these patterns. * @returns {object} a new ProxyClient instance. */ function ProxyClient(iframeid, origin, originPatterns) { var _self = this; // Private map of success/error callbacks and deferreds by guid (the guid // is generated when the request is made). When a post message is // recieved, the message handler looks for the guid, retrieves the // callbacks and promise, calls any defined callbacks as appropriate, // resolves the promise, and finally deletes the callbacks so they can // only be called once and the map doesn't grow indefinitely. var callbacks = {}; /** * Use this method just as you would use $.ajax, with the following * caveats: * <lu> * <li> * The only callbacks you can specify are success and error. * </li> * <li> * Any JSONP parameters don't make sense since we're using post message * to handle cross-origin requests. * </li> * <li> * You can't access the XHR since it only exists in the proxy frame. * </li> * <li> * Don't bother specifying an X-RequestDigest header. It will only get * overwritten by the ServiceProxy anyway, since the digest is site * collection specific so any digest obtained in the client will not * be valid in the proxy site collection. * </li> * </lu> * I expect there may be other minor differences for some $.ajax edge * cases I don't know, but for the most part typical uses of $.ajax will * work exactly the same with ProxyClient.ajax. * @param {object} options - the ajax options. See the jQuery documentation * for $.ajax and the MS documentation on SharePoint REST calls for * details, and of course note the caveats above. * @return (object) a jQuery deferred. */ this.ajax = function(options) { var iframe = document.getElementById(iframeid).contentWindow; // create a new guid for the request and store callbacks by guid options.guid = SP.Guid.newGuid().toString(); callbacks[options.guid] = { success: options.success, error: options.error, deferred: new $.Deferred() }; if(options.success) delete options.success; if(options.error) delete options.error; // post the options as a message to the proxy frame iframe.postMessage(JSON.stringify(options), origin); // return the deferred return callbacks[options.guid].deferred; }; // Crate a listener for postMessages and pass the data to this.ajax. window.addEventListener("message", function(e) { // we expect a string, if not the message may be for a // different listener if(typeof(e.data) !== "string") return; // deserialize the message var data = e.data.trim(); try { data = JSON.parse(data); } catch(e) {} // we expect an object, with an options property that is also // an object, if not the message may be for a different listener if(typeof(data) !== "object" || typeof(data.options) !== "object") return; // if we have callbacks for this message if(data.options.guid && callbacks[data.options.guid]) { if(window !== window.top) { // throw an exception of origin does not meet expectations gaurde(e.origin, originPatterns, "Origin"); } // get the callbacks var cbs = callbacks[data.options.guid]; // call success on status === "ok" if(data.status === "ok") { if(typeof(cbs.success) === "function") cbs.success(data.message, data); // also resolve the promise cbs.deferred.resolve(data.message, data); } // call error on any other status else if(data.status !== "ok") { if(typeof(cbs.error) === "function") cbs.error(data.message, data); // also reject the promise cbs.deferred.reject(data.message, data); } // remove the callbacks so the map does not grow indefinitely delete callbacks[data.options.guid]; } }, false); } // attach class definition to namespace SPCORS.ProxyClient = ProxyClient; })(); |
Wrap-Up
You should now have enough information to be dangerous wrt Cross-Origin Resource Sharing (CORS). In order to make it work, you need to be able to place some code on both the source and the destination site collection, or coordinate with someone who has that access, which is why this is more secure than the old days of all SharePoint site collections existing in a single origin. And it does add a bit of complexity, but with a simple CORS wrapper like the one I’ve used here, you can make daily use of CORS no more complex than using REST directly (which as previously stated, is complex enough).
This CORS wrapper I’ve written is dependent on jQuery, mainly just so I can use $.ajax and promises without worrying too much about browser compatibility. If you’re already using jQuery for other things, then that makes sense. If not, it’s probably not worth including jQuery just for that. It wouldn’t be that hard to remove the jQuery dependency, and I could still use promises, if I switched to using something like fetch, which I’ll probably do in a follow up post at some point.
And keep in mind that I didn’t do anything with the response except popup a dialog so I didn’t need to do anything in particular to safeguard against a potentially malicious response. But if you’re going to put parts of the response directly in your HTML, you should escape it to remove the possibility of executing malicious code (like strip out script tags, and JavaScript in HTML attributes, etc.). And at a bare minimum, eval should be used sparingly in JavaScript if at all, but regardless of whether you agree with this statement or not, it should never be used on input that comes from a cross-origin resource. The point is that you can’t treat the response as coming from a fully trusted source.
The complete source code is in CORS_Wrapper_Source.zip.
References
- fetch API – David Walsh
- Introduction to fetch() – Matt Gaunt
- WHATWG fetch pollyfill – GitHub project