How to configure Anti-Forgery Protection in a view-less Web API

Viewed 1919

I'm implementing a REST API using ASP.NET Core. It is stateless except for the fact that is uses cookies for authentication and therefore is vulnerable to cross-site request forgery (CSRF) attacks.

Luckily, ASP.NET Core provides means as a protection against that: Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core.

As my application does not have any views or pages, I'm only configuring my controllers using services.AddControllers() in my Startup.

When hitting a REST endpoint that is attributed with [ValidateAntiForgeryToken], I get the following exception:

System.InvalidOperationException: No service for type 'Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.ValidateAntiforgeryTokenAuthorizationFilter' has been registered.

Registering my controllers using services.AddControllersWithViews() makes this error go away as it internally registers the appropriate service.

According to the docs:

Antiforgery middleware is added to the Dependency injection container when one of the following APIs is called in Startup.ConfigureServices:

AddMvc

MapRazorPages

MapControllerRoute

MapBlazorHub

All of these method seem to me to be view-centric (except MapControllerRoute which I'm doing in the Configure method in my Startup but it doesn't help) and part of the namespace of the missing service is ViewFeatures. This confuses me because in my understanding, and need to take care of CSRF although I'm developing a pure Web API without views.

Is my understanding wrong? How is CSRF protection configured when no views are involved?

2 Answers

I will suggest move away from the default ValidateAntiForgeryToken attribute

All the harder work is done by services.AddAntiforgery(), and the ValidateAntiForgeryToken just calls antiforgery.ValidateRequestAsync()

You can create your own filter for it and register it etc. but take a look at this neat implementation, you can simply inject an instance of IAntiforgery in all the POST api methods

https://github.com/dotnet/AspNetCore.Docs/blob/main/aspnetcore/security/anti-request-forgery/sample/AngularSample/Startup.cs

Here are what I believe to be bits of the Microsoft docs you link to on how to handle this. They say that "using local storage to store the antiforgery token on the client and sending the token as a request header is a recommended approach." They also go on to say that the approach is to use middleware to generate an antiforgery token and send it in the response as a cookie. In short they are saying if you have an API put the antiforgery token in a cookie.

As you say with just AddControllers you can't use the [ValidateAntiForgeryToken]. As LarryX says the thing to do is create your own filter.

In case it helps anyone I have created a demo app that uses some custom middleware to check for the antiforgery token if the request is not a GET.
Note that the CORS code is just there so that I could make a post from another domain to test the code works (I tested with https://localhost:44302).

Standard Program.cs (nothing interesting here)

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace SpaAntiforgery
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

Startup.cs

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;

namespace SpaAntiforgery
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCors();
            services.AddControllers();
            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
             .AddCookie();
            
            services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
        }

        public void Configure(IApplicationBuilder app, IAntiforgery antiforgery)
        {
            //CORS code that is needed if you want another domain to access your API
            app.UseCors(
               options => options.WithOrigins("https://localhost:44302")
           .AllowAnyMethod()
           .AllowCredentials()
           .WithHeaders("x-csrf-token", "content-type"));

            app.UseRouting();
            app.UseAuthentication();
            app.UseAuthorization();


            //this bit is straight form the Microsoft docs.  See the link reference at the start of my answer
            app.Use(next => context =>
            {
                string path = context.Request.Path.Value;

                if (
                    string.Equals(path, "/", StringComparison.OrdinalIgnoreCase) ||
                    string.Equals(path, "/index.html", StringComparison.OrdinalIgnoreCase))
                {
                    // The request token can be sent as a JavaScript-readable cookie,                    
                    var tokens = antiforgery.GetAndStoreTokens(context);
                    context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken,
                        new CookieOptions() { HttpOnly = false });
                }
                return next(context);
            });          


            //this is my custom middleware that will test for the antiforgery token if the request is not a GET
            app.EnsureAntiforgeryTokenPresentOnPosts();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
                endpoints.MapFallbackToController("Index", "Home");
            });
        }
    }
}

Here is the custommiddleware code that is needed for app.EnsureAntiforgeryTokenPresentOnPosts();

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;

namespace SpaAntiforgery
{
    public class AppEnsureAntiforgeryTokenPresentOnPostsMiddleware
    {

        private readonly RequestDelegate _next;
        private readonly IAntiforgery _antiforgery;

        public AppEnsureAntiforgeryTokenPresentOnPostsMiddleware(RequestDelegate next, IAntiforgery antiforgery)
        {
            _next = next;
            _antiforgery = antiforgery;
        }     
        public async Task Invoke(HttpContext httpContext)
        {
            var notAGetRerquest = !string.Equals("GET", httpContext.Request.Method, StringComparison.OrdinalIgnoreCase);

            if (notAGetRerquest)
            {
                // This will throw if the token is invalid.
                await _antiforgery.ValidateRequestAsync(httpContext);
            }
            await _next(httpContext);
        }
    }

    public static class AppEnsureAntiforgeryTokenPresentOnPostsExtension
    {
        public static IApplicationBuilder EnsureAntiforgeryTokenPresentOnPosts(
          this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<AppEnsureAntiforgeryTokenPresentOnPostsMiddleware>();
        }

    }
}

HomeController.cs The idea is to make a get to this endpoint so that your client code can retrieve the antiforgery token.

using Microsoft.AspNetCore.Mvc;

namespace SpaAntiforgery.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class HomeController: ControllerBase
    {
        public IActionResult Index()
        {
            return Ok();
        }
    }
}

I also included a controller to test out a post.

using Microsoft.AspNetCore.Mvc;

namespace SpaAntiforgery.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class TestAntiforgeryController : ControllerBase
    {
        [HttpPost]
        public IActionResult Index()
        {
            return Ok();
        }
    }
}

Sending a post request to /testantiforgery using something like Postman results in an error because the post does not include the antiforgery token. This is what we want.

In order to test that a successful post can be made I created another website with the following code. Note the getCookie method comes straight from the Microsoft docs that I linked to at the start of my answer.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <button id="MyButton">
        Test
    </button>   
    
  <script>
        const getCookie = cookieName => {
            var name = cookieName + "=";
            var decodedCookie = decodeURIComponent(document.cookie);
            var ca = decodedCookie.split(";");
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i];
                while (c.charAt(0) == " ") {
                    c = c.substring(1);
                }
                if (c.indexOf(name) == 0) {
                    return c.substring(name.length, c.length);
                }
            }
            return "";
        };

        const getCsrfToken = () => {
            return getCookie("CSRF-TOKEN");
        };


        const getHeadersIncludingCsrfToken = () => {
            const defaultHeaders = {
                Accept: "application/json",
                "Content-Type": "application/json"
            };
            return { ...defaultHeaders, "X-CSRF-TOKEN": getCsrfToken()};
        };

        const sendRequest = async (url, settings, done) => {

            const baseUrl = "https://localhost:44333";
            const response = await fetch(baseUrl + url, settings);
            if (response.status !== 200) {
                console.log("there was an api error");
                return;
            }
            done();
        };

        const sendGet = async (url, done) => {
            const settings = {
                method: "GET"
            };

            await sendRequest(url, settings, done);
        };

        const sendPost = async (url, done) => {
            const settings = {
                method: "POST",
                headers: getHeadersIncludingCsrfToken()
            };
            settings.credentials = "include";
            await sendRequest(url, settings, done);
        };

        const sendAPost = () => {
            sendPost("/testantiforgery", () => console.log("post succeeded!"));
        }

        const onTest = () => {
            //sending a get to / means the antiforgery cookie is sent back
            sendGet("/", sendAPost);
        };

        const MyButton = document.getElementById("MyButton");
        MyButton.addEventListener("click", onTest);
    </script>    
</body>
</html>

As you can see from the javascript code, after clicking the button, the code sends a GET, this is just to retreive the antiforgery token. The GET is followed by a post. The CSRF-TOKEN is retreived from the cookies and included in the request headers. Note if trying this code out for yourself you will need to set your own baseUrl in the javascript code and also set your own url in the UseCors method in the Configure of Startup.

Related