How to send an HTTP 4xx-5xx response with CORS hea

2020-03-24 08:25发布

问题:

I have a standard ASP.NET Core 2 Web App acting as a REST/WebApi. For one of my endpoints I return an HTTP 400 when the user provides bad search/filter querystring arguments.

Works great with POSTMAN. But when I try and test this with my SPA app (which in effect is now crossing domains and thus doing a CORS request), I get a failure in Chrome.

When doing a CORS request to an endpoint that returns an HTTP 200 response, all works fine.

It looks like my error handling is NOT taking into consideration the CORS stuff (i.e. not adding any CORS headers) and isn't including that.

I'm guessing I'm messing up the response payload pipeline stuff.

Q: Is there a way to correct return any CORS header information in a custom Error Handling without hardcoding the header but instead using the headers stuff that was setup in the Configure/ConfigureServices methods in Startup.cs?

Pseduo code..

public void ConfigureServices(IServiceCollection services)
{
    ... snip ...

    services.AddMvcCore()
        .AddAuthorization()
        .AddFormatterMappings()
        .AddJsonFormatters(options =>
        {
            options.ContractResolver = new CamelCasePropertyNamesContractResolver();
            options.Formatting = Formatting.Indented;
            options.DateFormatHandling = DateFormatHandling.IsoDateFormat;
            options.NullValueHandling = NullValueHandling.Ignore;
            options.Converters.Add(new StringEnumConverter());
        })
        .AddCors(); // REF: https://docs.microsoft.com/en-us/aspnet/core/security/cors#setting-up-cors

    ... snip ...
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    ... snip ...

    app.UseExceptionHandler(options => options.Run(async httpContext => await ExceptionResponseAsync(httpContext, true)));

    app.UseCors(builder => builder//.WithOrigins("http://localhost:52383", "http://localhost:49497")
                                .AllowAnyOrigin()
                                .AllowAnyHeader()
                                .AllowAnyMethod());

    ... snip ...
}

private static async Task ExceptionResponseAsync(HttpContext httpContext, bool isDevelopmentEnvironment)
{
    var exceptionFeature = httpContext.Features.Get<IExceptionHandlerPathFeature>();
    if (exceptionFeature == null)
    {
        // An unknow and unhandled exception occured. So this is like a fallback.
        exceptionFeature = new ExceptionHandlerFeature
        {
            Error = new Exception("An unhandled and unexpected error has occured. Ro-roh :~(.")
        };
    }

    await ConvertExceptionToJsonResponseAsyn(exceptionFeature,
                                                httpContext.Response, 
                                                isDevelopmentEnvironment);
}

private static Task ConvertExceptionToJsonResponseAsyn(IExceptionHandlerPathFeature exceptionFeature,
    HttpResponse response,
    bool isDevelopmentEnvironment)
{
    if (exceptionFeature == null)
    {
        throw new ArgumentNullException(nameof(exceptionFeature));
    }

    if (response == null)
    {
        throw new ArgumentNullException(nameof(response));
    }

    var exception = exceptionFeature.Error;
    var includeStackTrace = false;
    var statusCode = HttpStatusCode.InternalServerError;
    var error = new ApiError();

    if (exception is ValidationException)
    {
        statusCode = HttpStatusCode.BadRequest;
        foreach(var validationError in ((ValidationException)exception).Errors)
        {
            error.AddError(validationError.PropertyName, validationError.ErrorMessage);
        }
    }
    else
    {
        // Final fallback.
        includeStackTrace = true;
        error.AddError(exception.Message);
    }

    if (includeStackTrace &&
        isDevelopmentEnvironment)
    {
        error.StackTrace = exception.StackTrace;
    }

    var json = JsonConvert.SerializeObject(error, JsonSerializerSettings);
    response.StatusCode = (int)statusCode;
    response.ContentType = JsonContentType;
    // response.Headers.Add("Access-Control-Allow-Origin", "*"); <-- Don't want to hard code this.
    return response.WriteAsync(json);
}

Cheers!

回答1:

In the ExceptionHandler middleware, the Response is cleared before being passed into your own middleware function, as can be seen in the source:

try
{
    await _next(context);
}
catch (Exception ex)
{
    // ...
    context.Response.Clear();

    // ...
    await _options.ExceptionHandler(context);

    // ..
}

Of course, this means that any response headers that might have been set in respect to CORS are also being cleared.

The following code plugs in to the general CORS system, and I believe does appear to mostly satisfy your requirement that the configuration from ConfigureServices can be used:

var corsService = httpContext.RequestServices.GetService<ICorsService>();
var corsPolicyProvider = httpContext.RequestServices.GetService<ICorsPolicyProvider>();
var corsPolicy = await corsPolicyProvider.GetPolicyAsync(httpContext, null);

corsService.ApplyResult(
    corsService.EvaluatePolicy(httpContext, corsPolicy),
    httpContext.Response);

GetPolicyAsync takes the name of a policy as the second parameter - If this is null (as in my example), it will use the default policy, if this has been set up.

I've not included null-checks or anything in the code example, in order to keep it focussed, but this approach is working in a test project I have built.

This approach is heavily influenced by the CorsAuthorizationFilter source code in Microsoft.AspNetCore.Mvc.Cors.

EDIT: You're not using a named policy in your example code, but you can switch over to one using the following:

.AddCors(corsOptions => corsOptions.AddPolicy(
    "Default",
    corsPolicyBuilder => corsPolicyBuilder
        .AllowAnyOrigin()
        .AllowAnyHeader()
        .AllowAnyMethod()));

This uses AddPolicy - I mentioned AddDefaultPolicy in the comments, but it looks like this is not in the current release and so not available yet. With the changes above, you can just call UseCors like so:

app.UseCors("Default");

The final change is to update to the following in your exception handling code:

await corsPolicyProvider.GetPolicyAsync(httpContext, "Default");

You'd be better off using some sort of const string for this, especially as it's likely all running from the same file. The main change here is no longer attempting to use the default named policy, as I was looking at the current version of the source code on GitHub that is yet to be released.