DEV Community

Cover image for Kentico 12: Design Patterns Part 19 - Protecting An API Against XSRF
Sean G. Wright
Sean G. Wright

Posted on

Kentico 12: Design Patterns Part 19 - Protecting An API Against XSRF

Photo by marcos mayer on Unsplash

What is XSRF (Cross-Site Request Forgery)

XSRF (or CSRF) stands for Cross-Site Request Forgery and is potential vulnerability of websites that allows for attackers to leverage the authentication cookies in a user's browser for a given site without the user being aware ๐Ÿค”.

More information about XSRF can be found on Wikipedia.


XSRF and Kentico 12 MVC

Kentico provides XSRF protection out-of-the-box when doing Portal Engine development ๐Ÿ’ช.

When building an MVC site with Kentico 12, the XSRF protection is provided by MVC and needs to be applied by the developer.

From the Kentico documentation:

For MVC sites with pages handled by controllers and views, you need to add the ValidateAntiForgeryToken attribute to your action methods, and generate security tokens by calling the @Html.AntiForgeryToken() method in your MVC views that post to the action methods.

This protection against XSRF has some limitations, namely these tokens aren't going to be unique per user/request if we have output caching enabled for the page they are used on ๐Ÿ˜‘.

If we disable output caching on pages with forms, then everything works great... right?

Well, that depends on how we are submitting the form.

XSRF and XHR - What's the Big Deal?

Form vs XHR POST For an API

When submitting a form in the traditional manner, via a <form> element on a page, the form is submitted with the anti-forgery token (assuming @Html.AntiForgeryToken() was added to the View).

The server evaluates the token and allows the form submission to be processed if the token is valid.

What if we are submitting forms, not by a traditional <form> POST but by XHR ๐Ÿ˜•?

XHR, or XMLHttpRequest, for those not familiar, is how we send requests from a loaded page in the browser to a server, without having to request an entirely new page.

When I write XHR, the tech used isn't important - it could be XMLHttpRequest, jQuery.ajax, fetch, axios, ect...

We might find ourselves creating API endpoints in our MVC applications that accept a JSON encoded request payload of form data.

These will be POST requests, but they won't include the anti-forgery tokens unless we explicitly add that data to the request. Additionaly, the built-in MVC ValidateAntiForgeryToken action filters won't understand how to parse a JSON request to validate the anti-forgery token ๐Ÿคฆ๐Ÿฝโ€โ™€๏ธ.

XHR, CORS, and JSON

So it seems as though our XHR initiated JSON requests of form data can't be easily protected against XSRF attacks.

We might ask ourselves at this point, is this protection even necessary?

As it turns out, if we have CORS configured correctly in our application, we don't have to worry about XHR/client-initiated requests from a malicious site, submitting data on behalf of a user with their authentication cookie, because CORS (actually, the Same Origin Policy) won't allow the browser to make the request in the first place ๐Ÿ˜….

Unfortunately, the browser can't do anything about a traditional <form> POST from a malicious site, and yes, it is possible to submit JSON via <form> ๐Ÿ˜–.

So, we still need to protect our JSON-based API endpoints (specifically POST-endpoints) against XSRF and MVC doesn't supply us anything for it.


Implementing XHR XSRF Protection

Since we know MVC already has an XSRF protection in the code base, let's go to the source to see how it all works ๐Ÿง.

The general approach we'll take was detailed by Phil Haack way back in 2011. We will be making custom versions of the XSRF types provided by MVC and wiring those up ourselves ๐Ÿค“.

Creating an Action Filter

We first need to create our own MVC Action Filter that will analyze requests and determine if any anti-forgery token verification needs to take place.

I'm using Autofac as my IoC container, and since it supplies custom IAutofacActionFilter I will be implementing this interface instead of IActionFilter - however, either approach works fine.

Let's take a look at the first part of the class:

public sealed class ValidateAntiForgeryTokenApiFilter : IAutofacActionFilter
{
    public const string XsrfHeader = "XSRF-TOKEN";
    public const string XsrfCookie = "__RequestVerificationToken";

    private readonly IUserContext userContext;
    private readonly IAntiForgeryService antiForgeryService;
    private readonly IPageBuilderUrlMatcher pageBuilderUrlMatcher;

    public ValidateAntiForgeryTokenApiFilter(
        IUserContext userContext,
        IAntiForgeryService antiForgeryService,
        IPageBuilderUrlMatcher pageBuilderUrlMatcher)
    {
        this.userContext = userContext;
        this.antiForgeryService = antiForgeryService;
        this.pageBuilderUrlMatcher = pageBuilderUrlMatcher;
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

We define some constants, namely the HTTP header that will contain the anti-forgery token value coming from the client, and the name of the cookie that will also contain the anti-forgery token value on every HTTP request to the app.

We also inject some dependencies to keep our ValidateAntiForgeryTokenApiFilter focused on processing requests and not trying to do everything else.

Now, let's look at the filter method that will be executed on every HTTP request:

public Task OnActionExecutingAsync(
    HttpActionContext actionContext,
    CancellationToken cancellationToken)
{
    if (!DoesRequestNeedValidated(actionContext))
    {
        return Task.CompletedTask;
    }

    var headers = actionContext.Request.Headers;

    if (!headers.TryGetValues(XsrfHeader, out var xsrfTokenList))
    {
        throw new XsrfTokenMissingException();
    }

    string tokenHeaderValue = xsrfTokenList.First();

    var tokenCookie = actionContext
        .Request
        .Headers
        .GetCookies()
        .Select(c => c[XsrfCookie])
        .FirstOrDefault();

    if (tokenCookie is null)
    {
        throw new XsrfTokenMissingException();
    }

    if (!antiForgeryService.IsValid(tokenCookie.Value, tokenHeaderValue))
    {
        throw new XsrfTokenMissingException();
    }

    return Task.CompletedTask;
}
Enter fullscreen mode Exit fullscreen mode

A quick review of this method shows we perform a couple of steps to validate a request:

  1. If the "Request needs validated", we proceed, otherwise we skip the validation process
  2. If the HTTP headers don't include our anti-forgery header, we fail by throwing a custom XsrfTokenMissingException
  3. If the header is found but the value is null we throw a XsrfTokenMissingException
  4. If the service responsible for validating the cookie and header values returns false, we throw a XsrfTokenMissingException, otherwise validation succeeds

Now let's look at how we determine DoesRequestNeedValidated:

public bool DoesRequestNeedValidated(HttpActionContext context)
{
    var method = context.Request.Method;

    if (pageBuilderUrlMatcher.IsMatch(context.Request.RequestUri.AbsolutePath))
    {
        return false;
    }

    if (method != HttpMethod.Post)
    {
        return false;
    }

    if (!userContext.IsAuthenticated)
    {
        return false;
    }

    var controllerAttributes = context
        .ControllerContext
        .ControllerDescriptor
        .GetCustomAttributes<SkipAntiForgeryTokenValidationApiAttribute>(false);

    var actionAttributes = context
        .ActionDescriptor
        .GetCustomAttributes<SkipAntiForgeryTokenValidationApiAttribute>(false);

    bool hasSkipAttribute = actionAttributes
        .Union(controllerAttributes)
        .Any();

    if (hasSkipAttribute)
    {
        return false;
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

To determine if a request should be validated we ask a couple questions of the request:

  1. Is it for a Kentico MVC Page Builder URL (something an inline editor might interact with)?
  2. Is it using a verb other than POST?
  3. Is the user not authenticated?
  4. Are there any SkipAntiForgeryTokenValidationApiAttribute attributes on the API Controller or endpoint action method?

If any of the above is true, we skip validating, otherwise we validate the request.

That's it for ValidateAntiForgeryTokenApiFilter, so let's move on to how we actually validate our tokens ๐Ÿ‘.

Creating and Validating Anti-Forgery Tokens

The IAntiForgeryService that was used by our ValidateAntiForgeryTokenApiFilter has an implementation as follows:

public class AntiForgeryService : IAntiForgeryService
{
    private readonly IHttpContextBaseAccessor httpContextBaseAccessor;

    public AntiForgeryService(IHttpContextBaseAccessor httpContextBaseAccessor)
    {
        this.httpContextBaseAccessor = httpContextBaseAccessor;
    }

    public bool IsValid(string cookieToken, string formToken)
    {
        try
        {
            AntiForgery.Validate(cookieToken, formToken);
        }
        catch (HttpAntiForgeryException)
        {
            return false;
        }

        return true;
    }

    public string UpdateAntiForgery()
    {
        var cookie = httpContextBaseAccessor
            .HttpContextBase
            .Request
            .Cookies
            .Get(AntiForgeryConfig.CookieName);

        // This comes from the MVC source code
        AntiForgery.GetTokens(
            cookie?.Value, 
            out string newCookieToken, 
            out string formToken);

        string cookieName = AntiForgeryConfig.CookieName;
        string cookieValue = newCookieToken ?? cookie?.Value;

        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
        });

        httpContextBaseAccessor
            .HttpContextBase
            .GetOwinContext()
            .Response
            .Cookies
            .Append(cookieName, cookieValue, cookieOptions);

        return formToken;
    }
}
Enter fullscreen mode Exit fullscreen mode

This class has two methods. The first checks if a cookie value matches an HTTP header value by using AntiForgery.Validate. Since AntiForgery.Validate throws an HttpAntiForgeryException when validation fails, we wrap the call in a try/catch so we can convert that to a true/false.

The second method generates a new anti-forgery cookie. This functionality is important because we will need to give the client the HTTP header value that would normally be inserted into a <form> via @Html.AntiForgeryToken() and ensure that matches up with the cookie value sent from the server back to the browser.

XSRF API Endpoint

We need to expose an API endpoint that the browser can request when a page loads that will return both the HTTP header value and a cookie with the same matching value.

Not sure how to create an API in your Kentico 12 MVC application? Check out my post Kentico 12: Design Patterns Part 7 - Integrating Web API 2:


This could look something like the following:

[RoutePrefix("xsrf")]
public class XsrfApiController : ApiController
{
    private readonly IAntiForgeryService antiForgeryService;

    public XsrfApiController(IAntiForgeryService antiForgeryService)
    {
        this.antiForgeryService = antiForgeryService;
    }

    [Route("")]
    [ResponseType(typeof(OkResult))]
    public IHttpActionResult PutXsrfToken() =>
        Ok(new { token = antiForgeryService.UpdateAntiForgery() });
}
Enter fullscreen mode Exit fullscreen mode

Since this anti-forgery token is returned via API, we can apply output caching to each of our pages that contain forms, without having to worry about the anti-forgery value also being cached ๐Ÿ‘.

Our client JavaScript application would consume this endpoint as follows:

let xsrfToken = '';

window.addEventListener('DOMContentLoaded', function () {
    fetch('/api/xsrf', { method: 'PUT' })
        .then(resp => resp.json())
        .then(({ token }) => xsrfToken = token);
});

document.querySelector('#my-form').addEventListener('submit', function (e) {
    e.preventDefault();

    const formData = // get the form values ...

    fetch('/api/form-endpoint', { 
        method: 'POST',
        body: JSON.stringify(formData),
        headers: {
            'Content-Type': 'application/json',

            // This header/value matches up with the header we look for
            // in ValidateAntiForgeryTokenApiFilter

            'XSRF-TOKEN': xsrfToken
        }
    })
});
Enter fullscreen mode Exit fullscreen mode

We can see here that we request the anti-forgery token from the /api/xsrf endpoint as soon as the browser has loaded the page.

We store that token in a variable and send it back to the API, as a header, when we submit the form.

With all these pieces in place we can now protect against XSRF attacks that would be directed at our JSON-based APIs. The XHRs will contain the anti-forgery headers and be correctly validated ๐Ÿ˜Ž. Any malicious from POST requests coming from other sites will be missing the token and fail ๐Ÿ‘ฎ๐Ÿฝโ€โ™€๏ธ.


Caveats

I would like to mention issues we might encounter when implementing this XSRF protection pattern.

First, we need a way to ensure all XHRs get the header added correctly, which means the token we receive from our API must be globally accessible. localStorage is an option or if we use a library like Axios, we can leverage its interceptors as a central place to insert headers for all requests.

Second, we can't send POST requests from the client until we have received a response from /api/xsrf, otherwise it will be missing the correct header.

We can ensure our requests happen serially through promises, callbacks, or async/await, but we need to handle this differently for each application architecture.

Third, the anti-forgery value and cookie are tied to a user's Session. This means if the session times out and XHR requests are made without session being refreshed, the anti-forgery check will fail ๐Ÿคจ.

There are various ways to handle this gracefully, like retrying requests, that fail with specific anti-forgery errors, after updating the anti-forgery header and cookie values.

We could also log the user out from the client when we see these errors and treat them as an indication that the user's Session has expired.


Conclusion

Security is tough ๐Ÿ˜ต! Kentico and MVC provide us with a lot of tools out of the box to ensure we can protect our users and our applications.

However, due to the flexibility of the architectures of modern web applications, we might need to come up with our own security verification processes to handle special use-cases.

It might be debatable whether or not we really need to protect JSON based API endpoints from XSRF attacks, but at least we now know there is an approach if we feel its prudent ๐Ÿ‘.

By copying the pattern that ASP.NET MVC uses for its traditional forms-based XSRF protection, we can develop a solution that handles protecting our API endpoints as well.

As always, thanks for reading ๐Ÿ™!


If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:

#kentico

Or my Kentico blog series:

Top comments (0)