If you are working with WebAPIs, you inevitably will have to deal with CORS.
This is a good read for the uninitiated.
I won't go into the specifics of how browsers issue, and servers handle (or should handle) CORS, but at a high level,
the following sequence of events occur when you need to update/create a resource from a domain other than the one your app
originates from.
- Client web app from domain "foo.com" issues a POST/PUT/PATCH to a resource on domain "bar.com"
- The browser needs to know whether the server can handle said request so it sends a precursor request to the
actual request, know as a "pre-flight" which uses the OPTIONS verb to the same endpoint. The CORS spec says that OPTIONS requests need not include any auth credentials.
- The server typically responds with HTTP 200 and a bunch of response headers letting the browser know that it's
willing to serve the "actual" request. If the server responds with any failure HTTP status code, the "true"
request and payload are never sent to the server and the operation is deemed as failed.
- If a 200 is received with the necessary response headers, the browser then sends the "true request" to the end point
and the server handles the operation accordingly.
If you run the debugger tools on most modern browsers, you will see the above sequence of events and requests being issued.
IE tends to be a bit different, in that, it uses an XDomain object instead XHR(XmlHttpRequest) compared to browsers like
Chrome and FF which use XHR. Go figure!
This subtle difference in how browsers issue cross domain requests is a major PITA when your WebAPIs are secured
with some authentication scheme.You do secure your WebAPIs don't you!
The simplest auth scheme and also the most common one used in an intranet scenario with IIS is WindowsAuthentication
using NTLM/Kerberos.
Since IIS doesn't allow for conditionally enabling/disabling Windows Auth it's an all or nothing deal. This means that OPTIONS requests are outright rejected by IIS before even hitting application code since the request won't contain any auth information. Now, you could enable anonymous auth but this won't cut it either since the request still flows through the IIS pipeline and will inevitably hit the windows auth module and you inevitable get a 401.x response. Secondly, most right minded developers would not feel comfortable enabling Anonymous auth and the security policy in most enterprises won't even allow this. So what do you do?
If you are running on WebAPI 2.x you could install Microsoft.AspNet.WebApi.Cors
Basically, this installs a DelegatingHandler
to handle the HttpRequestMessage in the ASPNET pipeline before it hits your controller/actions/filters etc.
Folks have had varying levels of success with this package.
Personally, I've not been able to get this to work on WebAPI 2.x with Windows Auth and if you have any
WebAPI 1.x based code, this package is not an option since it applies only to WebAPI 2.x
Another option is to add the following response headers to the Web.config, these
headers are expected in response to a pre-flight request.
<system.webServer>
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Credentials" value="true"/>
<add name="Access-Control-Allow-Origin" value="*" />
<add name="Access-Control-Allow-Methods" value="GET, PUT, POST, DELETE, HEAD, PATCH" />
<add name="Access-Control-Allow-Headers" value="Origin, X-Requested-With, content-type, Accept" />
<add name="Access-Control-Max-Age" value="1728000"/>
</customHeaders>
</httpProtocol>
</system.webServer>
While this does provide the necessary response headers that XHR expects for the
pre-flights to succeed, it does not account for windows authentication which kicks
in and rejects the request with a 401 Unauthorized error!
Moreover, I'm not a big fan of using "*" for the origin, leaving it wide open, and neither am
I fond of specifying every single domain that I am willing to play ball with.
The only way to have your cake and eat it too is to roll up your sleeves and write
a HTTP module and handler which plugs into the IIS pipeline, giving you a chance
to inspect the request and take the appropriate course of action before authentication
kicks in.
namespace WebAPI.Infrastructure
{
using System;
using System.Web;
using System.Collections;
using System.Net;
public class CrossOriginModule : IHttpModule
{
public String ModuleName
{
get { return "CrossOriginModule"; }
}
public void Init(HttpApplication application)
{
application.BeginRequest += (new EventHandler(this.Application_BeginRequest));
}
private void Application_BeginRequest(Object source, EventArgs e)
{
HttpApplication application = (HttpApplication)source;
HttpContext context = application.Context;
CrossOriginHandler.AddCorsResponseHeaders(context);
}
public void Dispose()
{
}
}
public class CrossOriginHandler : IHttpHandler
{
#region Data Members
const string OPTIONS = "OPTIONS";
const string PUT = "PUT";
const string POST = "POST";
const string PATCH = "PATCH";
static string[] AllowedVerbs = new[] { OPTIONS, PUT, POST, PATCH };
const string Origin = "Origin";
const string AccessControlRequestMethod = "Access-Control-Request-Method";
const string AccessControlRequestHeaders = "Access-Control-Request-Headers";
const string AccessControlAllowOrigin = "Access-Control-Allow-Origin";
const string AccessControlAllowMethods = "Access-Control-Allow-Methods";
const string AccessControlAllowHeaders = "Access-Control-Allow-Headers";
const string AccessControlAllowCredentials = "Access-Control-Allow-Credentials";
const string AccessControlMaxAge = "Access-Control-Max-Age";
const string MaxAge = "86400";
#endregion
#region IHttpHandler Members
public bool IsReusable
{
get { return true; }
}
public void ProcessRequest(HttpContext context)
{
switch (context.Request.HttpMethod.ToUpper())
{
//Cross-Origin preflight request
case OPTIONS:
AddCorsResponseHeaders(context);
break;
default:
break;
}
}
#endregion
#region Static Methods
public static void AddCorsResponseHeaders(HttpContext context)
{
if (Array.Exists(AllowedVerbs, av => string.Compare(context.Request.HttpMethod, av, true) == 0))
{
var request = context.Request;
var response = context.Response;
var originArray = request.Headers.GetValues(Origin);
var accessControlRequestMethodArray = request.Headers.GetValues(AccessControlRequestMethod);
var accessControlRequestHeadersArray = request.Headers.GetValues(AccessControlRequestHeaders);
if (originArray != null &&
originArray.Length > 0)
response.AddHeader(AccessControlAllowOrigin, originArray[0]);
response.AddHeader(AccessControlAllowCredentials, bool.TrueString.ToLower());
if (accessControlRequestMethodArray != null &&
accessControlRequestMethodArray.Length > 0)
{
string accessControlRequestMethod = accessControlRequestMethodArray[0];
if (!string.IsNullOrEmpty(accessControlRequestMethod))
{
response.AddHeader(AccessControlAllowMethods, accessControlRequestMethod);
}
}
if (accessControlRequestHeadersArray != null &&
accessControlRequestHeadersArray.Length > 0)
{
string requestedHeaders = string.Join(", ", accessControlRequestHeadersArray);
if (!string.IsNullOrEmpty(requestedHeaders))
{
response.AddHeader(AccessControlAllowHeaders, requestedHeaders);
}
}
}
if (context.Request.HttpMethod == OPTIONS)
{
context.Response.AddHeader(AccessControlMaxAge, MaxAge);
context.Response.StatusCode = (int)HttpStatusCode.OK;
context.Response.End();
}
}
#endregion
}
}
We include the expected response headers in AddCorsResponseHeaders.
This is done by echoing back the header values from the request headers for the "Origin"
and the allowed verbs(methods).
This tells the browser that the server is willing to serve the request from
the calling domain (so no *) and is able to handle the requested
verb (PUT/POST/PATCH etc.)
For OPTIONS,we effectively short circuit the request pipeline, which is the
"pre-flight" that most browsers will issue and respond with a 200 OK.
The key here is to end the request processing via: context.Response.End();
This short circuits the request pipeline and stops any downstream authentication
modules from runnning.
The module can be installed by simply adding to Web.config like so:
<system.webServer>
<modules runAllManagedModulesForAllRequests="true">
<remove name="WebDAVModule" />
<add name="CrossOriginModule" preCondition="managedHandler" type="WebAPI.Infrastructure.CrossOriginModule, assemblyname" />
</modules>
<handlers>
<remove name="WebDAV"/>
<remove name="OPTIONSVerbHandler"/>
<remove name="ExtensionlessUrlHandler-ISAPI-4.0_32bit" />
<remove name="ExtensionlessUrlHandler-ISAPI-4.0_64bit" />
<remove name="ExtensionlessUrlHandler-Integrated-4.0" />
<add name="ExtensionlessUrlHandler-ISAPI-4.0_32bit" path="*."
verb="GET,HEAD,POST,DEBUG,PUT,DELETE,PATCH,OPTIONS" modules="IsapiModule" scriptProcessor="%windir%\Microsoft.NET\Framework\v4.0.30319\aspnet_isapi.dll" preCondition="classicMode,runtimeVersionv4.0,bitness32" responseBufferLimit="0" />
<add name="ExtensionlessUrlHandler-ISAPI-4.0_64bit" path="*."
verb="GET,HEAD,POST,DEBUG,PUT,DELETE,PATCH,OPTIONS" modules="IsapiModule" scriptProcessor="%windir%\Microsoft.NET\Framework64\v4.0.30319\aspnet_isapi.dll" preCondition="classicMode,runtimeVersionv4.0,bitness64" responseBufferLimit="0" />
<add name="ExtensionlessUrlHandler-Integrated-4.0" path="*."
verb="GET,HEAD,POST,DEBUG,PUT,DELETE,PATCH,OPTIONS" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" />
<add name="CrossOrigin" verb="OPTIONS" path="*" type="WebAPI.Infrastructure.CrossOriginHandler, assemblyname" />
</handlers>
<security>
<authorization>
<remove users="*" roles="" verbs=""/>
<add accessType="Allow" users="*" verbs="GET,HEAD,POST,PUT,PATCH,DELETE,DEBUG"/>
</authorization>
<requestFiltering>
<requestLimits maxAllowedContentLength="6000"/>
<verbs>
<remove verb="OPTIONS"/>
<remove verb="PUT"/>
<remove verb="PATCH"/>
<remove verb="POST"/>
<remove verb="DELETE"/>
</verbs>
</requestFiltering>
</security>
</system.webServer>
Points to note:
- The WebDAVModule has been removed
- The WebDAV and OPTIONSVerbHandler have been removed
- ExtensionlessUrlHandlers include the verbs used for updates (POST/PATCH etc)
- The requestfiltering section ensures that the necessary verbs are allowed.
This is key to ensuring IIS does not restrict the verbs of interest.
The above can all be wrapped up nicely in a Nuget package and installed in any
WebAPI or ASPNET project that needs support for CORS.
This is precisely what I'll do next and hopefully can host it on Nuget.
In the meantime, happy coding!