September 25th, 2015 by Adam Sandman
As part of our work on Spira v5.0 and the new plugin we're developing for the popular ZenDesk help desk system, we need to be able to make cross-original REST web service calls to Spira. For KronoDesk we used JSONP, but for ZenDesk we decided to add CORS (Cross Origin Resource Sharing) support to Spira 5.0 to make it easier. However implementing CORS support using WCF was not so easy so we're publishing our solution to help other software developers implement CORS using
What is CORS and why do we need it?
To prevent Cross Site Scripting (XSS) attacks, web browsers prevent a web page hosted at domain mydomain.com
from accessing a REST web service hosted at domain mydomain2.com
. As you would expect this severely limits how applications can limit each other. We came across the limitation when writing the plugin for our KronoDesk help desk system and used an older solution called JSONP (where the web server pads the returned JSON (hence JSONP) in a <script>
tag which browsers can call across different domains). This only supports GET requests so it is limited in what it can do, so we had to also implement a separate HTTP POST handler so that KronoDesk could POST data to Spira.
Fast forwarding to our new Spira 5.0 version and a plugin for ZenDesk that we're writing. We decided to support the new Cross Original Resource Sharing (CORS) standard as well as JSONP to make this integration easier and also get rid of the need for separate methods for retrieving data (GET using JSONP) and sending data (POST using HTTP FORM key/value data). Since Spira uses Windows Communication Foundation (WCF) to provide its SOAP and REST APIs we decided that this MSDN article would be very helpful. However in implementing CORS we found some issues in the article that we fixed. The next section explains the changes we made and includes some sample code.
Handling "Simple" CORS Requests
For simple CORS requests that use the GET verb all we need to do is have a list of allowed domains and inspect the incoming message for the special "Origin"
header and then if it matches (or if we have a * specified which means allow all domains) we send back the "Access-Control-Allow-Origin"
response.
To do this we need to add a new custom DispatchMessageInspector:
CorsEnabledMessageInspector.cs
public class CorsEnabledMessageInspector : IDispatchMessageInspector
{
public object AfterReceiveRequest(ref System.ServiceModel.Channels.Message request, System.ServiceModel.IClientChannel channel, System.ServiceModel.InstanceContext instanceContext)
{
HttpRequestMessageProperty httpProp = (HttpRequestMessageProperty)request.Properties[HttpRequestMessageProperty.Name];
string origin = httpProp.Headers[CorsConstants.Origin];
if (origin != null)
{
//See if this is one of our allowed origins, or we allow all
if (String.IsNullOrWhiteSpace(ConfigurationSettings.Default.Api_AllowedCorsOrigins))
{
return null;
}
string[] allowedOrigins = ConfigurationSettings.Default.Api_AllowedCorsOrigins.Split(',');
bool allowed = false;
foreach (string allowedOrigin in allowedOrigins)
{
if (allowedOrigin.Trim().ToLowerInvariant() == origin.Trim().ToLowerInvariant() || allowedOrigin.Trim() == CorsConstants.AllowOriginAll)
{
allowed = true;
break;
}
}
return (allowed) ? origin : null;
}
return null;
}
public void BeforeSendReply(ref System.ServiceModel.Channels.Message reply, object correlationState)
{
string origin = correlationState as string;
if (origin != null)
{
HttpResponseMessageProperty httpProp = null;
if (reply.Properties.ContainsKey(HttpResponseMessageProperty.Name))
{
httpProp = (HttpResponseMessageProperty)reply.Properties[HttpResponseMessageProperty.Name];
}
else
{
httpProp = new HttpResponseMessageProperty();
reply.Properties.Add(HttpResponseMessageProperty.Name, httpProp);
}
httpProp.Headers.Add(CorsConstants.AccessControlAllowOrigin, origin);
}
}
}
This is similar to the code in the MSDN article except that we didn't require each REST operation to be decorated by a special CORS attribute, we just allows all operations to use CORS if their domain is listed in the special ConfigurationSettings.Default.Api_AllowedCorsOrigins
user settings. This settings value is a basically a String that contains:
- A blank/null string - deny all domains
- An asterisk (*) - allow all domains
- A comma-separated list of domains (https://domain1.com, https://domain2.com) to be allowed
This custom Message Inspector is applied using a custom WCF Service Host and WCF Service Factory. We will show this code after we discuss Preflight Operations.
Handling "Preflight" CORS Requests
For HTTP verbs not considered "simple" where you will be updating the data (POST, PUT, DELETE) for security you need to first send a special HTTP OPTIONS request called a "preflight" operation that verifies the operation is allowed. We therefore need to extend WCF to have it dynamically create such operations automatically from the existing REST operations. To do this we need a custom CORS Preflight Invoker and Operation Behavior:
PreflightOperationBehavior.cs
///
/// The behavior for REST-CORS cross-domain preflight requests
///
class PreflightOperationBehavior : IOperationBehavior
{
private OperationDescription preflightOperation;
private List allowedMethods;
public PreflightOperationBehavior(OperationDescription preflightOperation)
{
this.preflightOperation = preflightOperation;
this.allowedMethods = new List();
}
public void AddAllowedMethod(string httpMethod)
{
this.allowedMethods.Add(httpMethod);
}
public void AddBindingParameters(OperationDescription operationDescription, BindingParameterCollection bindingParameters)
{
}
public void ApplyClientBehavior(OperationDescription operationDescription, ClientOperation clientOperation)
{
}
public void ApplyDispatchBehavior(OperationDescription operationDescription, DispatchOperation dispatchOperation)
{
int parameterCount = operationDescription.Messages[0].Body.Parts.Count;
dispatchOperation.Invoker = new PreflightOperationInvoker(operationDescription.Messages[1].Action, this.allowedMethods, parameterCount);
}
public void Validate(OperationDescription operationDescription)
{
}
}
PreflightOperationInvoker.cs
///
/// The behavior for REST-CORS cross-domain preflight requests
///
///
/// Invokes REST-CORS cross-domain preflight requests
///
class PreflightOperationInvoker : IOperationInvoker
{
private const string CLASS_NAME = "Inflectra.SpiraTest.Web.Services.Rest.PreflightOperationInvoker";
private string replyAction;
List allowedHttpMethods;
private int parameterCount;
public PreflightOperationInvoker(string replyAction, List allowedHttpMethods, int parameterCount)
{
this.replyAction = replyAction;
this.allowedHttpMethods = allowedHttpMethods;
this.parameterCount = parameterCount;
}
public object[] AllocateInputs()
{
return new object[this.parameterCount];
}
public object Invoke(object instance, object[] inputs, out object[] outputs)
{
const string METHOD_NAME = "Invoke";
Logger.LogEnteringEvent(CLASS_NAME + METHOD_NAME);
try
{
//We can ignore the inputs and just deal with the headers
MessageProperties incomingMessageProperties = OperationContext.Current.IncomingMessageProperties;
MessageProperties outgoingMessageProperties = OperationContext.Current.OutgoingMessageProperties;
outputs = null;
HandlePreflight(incomingMessageProperties, outgoingMessageProperties);
//We don't return any body
Logger.LogExitingEvent(CLASS_NAME + METHOD_NAME);
return null;
}
catch (Exception exception)
{
Logger.LogErrorEvent(CLASS_NAME + METHOD_NAME, exception);
throw;
}
}
public IAsyncResult InvokeBegin(object instance, object[] inputs, AsyncCallback callback, object state)
{
throw new NotSupportedException("Only synchronous invocation");
}
public object InvokeEnd(object instance, out object[] outputs, IAsyncResult result)
{
throw new NotSupportedException("Only synchronous invocation");
}
public bool IsSynchronous
{
get { return true; }
}
void HandlePreflight(MessageProperties incomingMessageProperties, MessageProperties outgoingMessageProperties)
{
HttpRequestMessageProperty httpRequest = (HttpRequestMessageProperty)incomingMessageProperties[HttpRequestMessageProperty.Name];
string origin = httpRequest.Headers[CorsConstants.Origin];
string requestMethod = httpRequest.Headers[CorsConstants.AccessControlRequestMethod];
string requestHeaders = httpRequest.Headers[CorsConstants.AccessControlRequestHeaders];
//Send the list of allowed methods in the output
HttpResponseMessageProperty httpResponse = new HttpResponseMessageProperty();
outgoingMessageProperties.Add(HttpResponseMessageProperty.Name, httpResponse);
httpResponse.SuppressEntityBody = true;
httpResponse.StatusCode = HttpStatusCode.OK;
//We don't need to add the Access-Control-Allow-Origin header because that was already added by the MessageInspector
//We do need to reply with the allowed methods
if (requestMethod != null && this.allowedHttpMethods.Contains(requestMethod))
{
httpResponse.Headers.Add(CorsConstants.AccessControlAllowMethods, string.Join(",", this.allowedHttpMethods));
}
if (requestHeaders != null)
{
httpResponse.Headers.Add(CorsConstants.AccessControlAllowHeaders, requestHeaders);
}
}
}
Finally we need to integrate these two concepts (the pre-flight operations creation code and the general CORS message inspector) into the WCF pipeline. This is done using a custom WebHttpBehavior, ServiceHost and ServiceHostFactory. These are included for completeness below:
RestServiceHostFactory.cs
public class RestServiceHostFactory : ServiceHostFactory
{
public override ServiceHostBase CreateServiceHost(string constructorString, Uri[] baseAddresses)
{
return base.CreateServiceHost(constructorString, baseAddresses);
}
protected override ServiceHost CreateServiceHost(Type serviceType, Uri[] baseAddresses)
{
//Create the service host
ServiceHost restServiceHost = new RestServiceHost(serviceType, baseAddresses);
//Add the custom security manager that handles the authentication header or URL token
restServiceHost.Authorization.PrincipalPermissionMode = System.ServiceModel.Description.PrincipalPermissionMode.Custom;
restServiceHost.Authorization.ServiceAuthorizationManager = new RestServiceAuthorizationManager();
return restServiceHost;
}
}
RestServiceHost.cs
///
/// WCF Service Host that dynamically creates the appropriate endpoints
///
///
/// Reduces the amount of configuration needed in Web.Config and avoids need to manually
/// create separate HTTP and HTTPS binding endpoints for each service
///
/// Also since v5.0 implements the CORS protocol for cross-domain REST requests
/// https://code.msdn.microsoft.com/Implementing-CORS-support-c1f9cd4b/
///
public class RestServiceHost : ServiceHost
{
public RestServiceHost()
{
}
public RestServiceHost(Type serviceType, params Uri[] baseAddresses)
: base(serviceType, baseAddresses)
{
}
public RestServiceHost(object singeltonInstance, params Uri[] baseAddresses)
: base(singeltonInstance, baseAddresses)
{
}
///
/// Responsible for actually provisioning the end points
///
protected override void ApplyConfiguration()
{
base.ApplyConfiguration();
//Create the endpoint behavior for the RESTful web services
//Also specify that it can dynamically use either XML or JSON depending on the Content-Type HTTP Header
RestWebHttpBehavior restWebHttpBehavior = new RestWebHttpBehavior();
restWebHttpBehavior.AutomaticFormatSelectionEnabled = true;
restWebHttpBehavior.DefaultOutgoingRequestFormat = System.ServiceModel.Web.WebMessageFormat.Json;
restWebHttpBehavior.DefaultOutgoingResponseFormat = System.ServiceModel.Web.WebMessageFormat.Json;
// Create the endpoint based on the service name and the binding derived from the scheme name
ContractDescription contract = ContractDescription.GetContract(this.Description.ServiceType);
bool httpBaseAddressAvailable = false;
bool httpsBaseAddressAvailable = false;
foreach (Uri address in this.BaseAddresses)
{
//Create the appropriate web binding for the URL scheme (http/https)
//All REST services are stateless so no need to use cookies
WebHttpBinding binding = new WebHttpBinding();
binding.MaxReceivedMessageSize = Int32.MaxValue;
binding.CrossDomainScriptAccessEnabled = true;
binding.AllowCookies = false;
if (address.Scheme.ToLowerInvariant() == "https")
{
binding.Security.Mode = WebHttpSecurityMode.Transport;
binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None;
httpsBaseAddressAvailable = true;
}
else
{
binding.Security.Mode = WebHttpSecurityMode.None;
httpBaseAddressAvailable = true;
}
//Set the reader quotas to unlimited
binding.ReaderQuotas.MaxArrayLength = Int32.MaxValue;
binding.ReaderQuotas.MaxBytesPerRead = Int32.MaxValue;
binding.ReaderQuotas.MaxDepth = Int32.MaxValue;
binding.ReaderQuotas.MaxNameTableCharCount = Int32.MaxValue;
binding.ReaderQuotas.MaxStringContentLength = Int32.MaxValue;
//Create the endpoint and specify its endpoint behavior
ServiceEndpoint serviceEndpoint = new ServiceEndpoint(contract, binding, new EndpointAddress(address));
serviceEndpoint.Behaviors.Add(restWebHttpBehavior);
//Add the endpoint
this.Description.Endpoints.Add(serviceEndpoint);
}
//Specify the service behavior. Easier to do it here than in the web.config file
//
List operationsToAddPreflight = new List();
foreach (OperationDescription operationDescription in contract.Operations)
{
DataContractSerializerOperationBehavior dataContractSerializer = operationDescription.Behaviors.Find();
if (dataContractSerializer == null)
{
dataContractSerializer = new DataContractSerializerOperationBehavior(operationDescription);
operationDescription.Behaviors.Add(dataContractSerializer);
}
dataContractSerializer.MaxItemsInObjectGraph = Int32.MaxValue;
//Add preflight support to this description if we have a CORS origin set
if (!String.IsNullOrWhiteSpace(ConfigurationSettings.Default.Api_AllowedCorsOrigins))
{
operationsToAddPreflight.Add(operationDescription);
}
}
//Add REST-CORS preflight OPTIONS operations as needed
Dictionary uriTemplates = new Dictionary();
foreach (OperationDescription operationDescription in operationsToAddPreflight)
{
AddPreflightOperation(operationDescription, uriTemplates);
}
//
ServiceMetadataBehavior serviceMetaData = this.Description.Behaviors.Find();
if (serviceMetaData == null)
{
serviceMetaData = new ServiceMetadataBehavior();
this.Description.Behaviors.Add(serviceMetaData);
}
//We dynamically set these based on the available base addresses
serviceMetaData.HttpGetEnabled = httpBaseAddressAvailable;
serviceMetaData.HttpsGetEnabled = httpsBaseAddressAvailable;
}
#region Preflight CORS Support
///
/// Adds a preflight operation
///
private void AddPreflightOperation(OperationDescription operation, Dictionary uriTemplates)
{
if (operation.Behaviors.Find() != null || operation.IsOneWay)
{
// no need to add preflight operation for GET requests, no support for 1-way messages
return;
}
UriTemplate originalUriTemplate;
WebInvokeAttribute originalWia = operation.Behaviors.Find();
if (originalWia != null && originalWia.UriTemplate != null)
{
originalUriTemplate = new UriTemplate (originalWia.UriTemplate);
}
else
{
originalUriTemplate = new UriTemplate (operation.Name);
}
string originalMethod = originalWia != null && originalWia.Method != null ? originalWia.Method : "POST";
UriTemplate matchingUriTemplate = uriTemplates.Keys.FirstOrDefault(u => u.IsEquivalentTo(originalUriTemplate));
if (matchingUriTemplate != null)
{
// there is already an OPTIONS operation for this URI, we can reuse it
PreflightOperationBehavior operationBehavior = uriTemplates[matchingUriTemplate];
operationBehavior.AddAllowedMethod(originalMethod);
}
else
{
ContractDescription contract = operation.DeclaringContract;
OperationDescription preflightOperation;
PreflightOperationBehavior preflightOperationBehavior;
CreatePreflightOperation(operation, originalUriTemplate, originalMethod, contract, out preflightOperation, out preflightOperationBehavior);
uriTemplates.Add(originalUriTemplate, preflightOperationBehavior);
contract.Operations.Add(preflightOperation);
}
}
///
/// Creates a special Preflight OPTIONS operation
///
///
///
///
///
///
///
private static void CreatePreflightOperation(OperationDescription operation, UriTemplate originalUriTemplate, string originalMethod, ContractDescription contract, out OperationDescription preflightOperation, out PreflightOperationBehavior preflightOperationBehavior)
{
preflightOperation = new OperationDescription(operation.Name + CorsConstants.PreflightSuffix, contract);
//First the input message
MessageDescription inputMessage = new MessageDescription(operation.Messages[0].Action + CorsConstants.PreflightSuffix, MessageDirection.Input);
preflightOperation.Messages.Add(inputMessage);
//We need to mirror the input parameters in the URI template
//First any variables in the path
if (originalUriTemplate.PathSegmentVariableNames != null && originalUriTemplate.PathSegmentVariableNames.Count > 0)
{
foreach (string uriParameter in originalUriTemplate.PathSegmentVariableNames)
{
inputMessage.Body.Parts.Add(new MessagePartDescription(uriParameter, "") { Type = typeof(string) });
}
}
//Next any in the querystring
if (originalUriTemplate.QueryValueVariableNames != null && originalUriTemplate.QueryValueVariableNames.Count > 0)
{
foreach (string uriParameter in originalUriTemplate.QueryValueVariableNames)
{
inputMessage.Body.Parts.Add(new MessagePartDescription(uriParameter, "") { Type = typeof(string) });
}
}
//Now the output message, we only need the CORS headers in reality
MessageDescription outputMessage = new MessageDescription(operation.Messages[1].Action + CorsConstants.PreflightSuffix, MessageDirection.Output);
//outputMessage.Body.ReturnValue = new MessagePartDescription(preflightOperation.Name + "Return", contract.Namespace) { Type = typeof(Message) };
preflightOperation.Messages.Add(outputMessage);
WebInvokeAttribute wia = new WebInvokeAttribute();
wia.UriTemplate = originalUriTemplate.ToString();
wia.Method = "OPTIONS";
preflightOperation.Behaviors.Add(wia);
preflightOperation.Behaviors.Add(new DataContractSerializerOperationBehavior(preflightOperation));
preflightOperationBehavior = new PreflightOperationBehavior(preflightOperation);
preflightOperationBehavior.AddAllowedMethod(originalMethod);
preflightOperation.Behaviors.Add(preflightOperationBehavior);
}
#endregion
}
RestWebHttpBehavior.cs
public class RestWebHttpBehavior : WebHttpBehavior
{
public override void ApplyDispatchBehavior(ServiceEndpoint endpoint, System.ServiceModel.Dispatcher.EndpointDispatcher endpointDispatcher)
{
base.ApplyDispatchBehavior(endpoint, endpointDispatcher);
if (endpointDispatcher != null && endpointDispatcher.DispatchRuntime != null && endpointDispatcher.DispatchRuntime.MessageInspectors != null)
{
//Remove the standard JavascriptCallbackMessageInspector
for (int i = 0; i < endpointDispatcher.DispatchRuntime.MessageInspectors.Count; i++)
{
if (endpointDispatcher.DispatchRuntime.MessageInspectors[i].GetType().Name == "JavascriptCallbackMessageInspector")
{
//Add the CORS inspector now
endpointDispatcher.DispatchRuntime.MessageInspectors.Add(new CorsEnabledMessageInspector());
}
}
}
}
}