In this post I'll show you how to use ASP.NET anti-forgery tokens in Sitecore JavaScript Services (JSS) to prevent cross-site request forgery (CSRF) attacks on custom forms.

TL;DR

This is a lengthy tutorial. If you want to jump straight into the code, it's all on GitHub here: https://github.com/coreyasmith/jss-anti-forgery-tokens. Or jump ahead to the implementation you're interested in:

The Approach

Preventing CSRF attacks is an easy two-step process with MVC:

  1. Add an anti-forgery token to forms in your Razor views with the @Html.AntiForgeryToken() HTML helper.
  2. Validate the anti-forgery token in your Controller actions with the [ValidateAntiForgeryToken] action filter.

The @Html.AntiForgeryToken() HTML helper does two things: adds a hidden <input /> element to your form and sends a cookie called __RequestVerificationToken to the browser. The [ValidateAntiForgeryToken] filter then checks that both the __RequestVerificationToken input and cookie values are present in the request and valid.

Web API doesn't ship with a [ValidateAntiForgeryToken] filter, but Sitecore has one in the Sitecore.Web assembly called [ValidateHttpAntiForgeryToken] you can use with ApiControllers.

For this post, we'll extend the Layout Service to use the @Html.AntiForgeryToken() HTML helper's underlying infrastructure to send an anti-forgery token to your JSS app. Then you'll be able to use the MVC and Sitecore anti-forgery token validators on the server to prevent CSRF attacks.

The JSS Component

From here on I'll assume that you've created a new JSS app using either the Angular, React, or Vue application templates. The component below is implemented in React, but the concepts will also work in an Angular or Vue app.

In this post we'll add an anti-forgery token to a simple form called JssRocksForm that sends a name back to the server.

JssRocksForm

Create a JssRocksForm component at /src/components/JssRocksForm/index.js.

import React, { Component } from "react";
import { isServer } from "@sitecore-jss/sitecore-jss";
import jssRocksApi from "../../api/jssRocksApi";

class JssRocksForm extends Component {
  state = {};

  componentDidMount = () => {
    if (!isServer()) {
      // Do not call the JSS Rocks API during server-side rendering as it can
      // cause performance issues
      jssRocksApi.getContact().then(contact => this.setState({ ...contact }));
    }
  };

  submitForm = event => {
    event.preventDefault();

    const { antiForgeryToken } = this.props.rendering;
    const data = new FormData(event.target);
    data.append(antiForgeryToken.name, antiForgeryToken.value);

    jssRocksApi
      .submitForm(data)
      .then(response => {
        if (!response.ok) {
          throw new Error("Uh oh.");
        }
        this.setState({ name: data.get("name") });
      })
      .catch(() => window.alert("Uh oh. Something bad happened!"));
  };

  render() {
    return (
      <React.Fragment>
        {this.state.name && <p className="lead">Hello, {this.state.name}!</p>}
        <form className="text-center" onSubmit={this.submitForm}>
          <div className="form-group">
            <input
              name="name"
              type="text"
              placeholder="Name"
              className="form-control"
              required={true}
            />
          </div>
          <button className="btn btn-primary">Submit</button>
        </form>
      </React.Fragment>
    );
  }
}

export default JssRocksForm;

In componentDidMount, we're using the isServer utility method from JSS to prevent the jssRocksApi from being called during server-side rendering (SSR). In connected mode, the jssRocksApi is going to call back to a Controller on our Sitecore server, and this can cause severe performance issues. Checking isServer will prevent the call from being made during SSR; instead the call will be made after the component loads in the browser.

In submitForm, we're adding the anti-forgery token to FormData just before passing it to the jssRocksApi. In a moment we'll add the anti-forgery token to props.rendering using the techniques I described in my posts on extending rendering data in disconnected mode and connected mode.

Register the component with the JSS manifest at /sitecore/definitions/components/JssRocksForm.sitecore.js:

// eslint-disable-next-line no-unused-vars
import { SitecoreIcon } from '@sitecore-jss/sitecore-jss-manifest';

export default function(manifest) {
  manifest.addComponent({
    name: 'JssRocksForm',
    displayName: 'JSS Rocks Form',
    icon: SitecoreIcon.Atom2,
    addAntiForgeryToken: true
  });
}

Notice the addAntiForgeryToken: true property. This is going to tell the disconnected Layout Service that the JssRocksForm requires an anti-forgery token, and it can also be leveraged in the import process.

Add the JssRocksForm component to the home route:

id: home-page
fields:
  pageTitle: JSS Rocks!
placeholders:
  jss-main:
  - componentName: ContentBlock
    fields:
      heading: JSS Rocks!
  - componentName: JssRocksForm

That's it for the component.

API Module

Create the following module in your JSS app at /src/api/jssRocksApi.js. This will handle communication with the server from your component.

import config from "../temp/config";

const urlEncodeFormData = (formData) => {
  const encodedEntries = [];
  for (var entry of formData.entries()) {
    encodedEntries.push(
      `${encodeURIComponent(entry[0])}=${encodeURIComponent(entry[1])}`
    );
  }
  const encodedFormData = encodedEntries.join('&').replace(/%20/g, '+');
  return encodedFormData;
}

class jssRocksApi {
  static apiUrl = `${config.sitecoreApiHost}/jssrocksapi/form?sc_apikey=${
    config.sitecoreApiKey
  }`;

  static getContact() {
    return fetch(jssRocksApi.apiUrl).then(response => response.json());
  }

  static submitForm(formData) {
    var encodedFormData = urlEncodeFormData(formData);
    return fetch(jssRocksApi.apiUrl, {
      method: "POST",
      credentials: "include", // required to send anti-forgery cookie
      headers: {
        "Content-Type": "application/x-www-form-urlencoded"
      },
      body: encodedFormData
    });
  }
}

export default jssRocksApi;

We're importing ../temp/config to get the URL of the server and the API key. In disconnected mode the URL will be localhost:3000; in connected mode it'll be the URL of your Sitecore instance. The API key will be used for CORS in connected mode.

To make this form play nicely with the MVC and Sitecore anti-forgery token validators, there are a couple of things to pay attention to in the submitForm method.

First, you must set credentials: same-origin in the fetch call to ensure that the anti-forgery token cookie is submitted to the server. Without this, the form data may be posted without the cookie and the anti-forgery token validators will fail.

Second, both the MVC and Sitecore anti-forgery token validators require the anti-forgery token form value be sent as application/x-www-form-urlencoded. This is a hard limitation--if you want to send a different Content-Type to the server, you'll have to write a custom anti-forgery token validator for both MVC and Web API, like this one. The urlEncodeFormData method converts the FormData object into a URL-encoded form to make the MVC and Sitecore anti-forgery token validators happy.

Disconnected Mode

In disconnected mode we can use the csurf middleware for Express.js to implement anti-forgery tokens. We will set up csurf to use the same anti-forgery form and cookie name as ASP.NET, __RequestVerificationToken, so it will behave just like the @Html.AntiForgeryToken HTML helper and the [ValidateAntiForgeryToken] and [ValidateHttpAntiForgeryToken] action filters.

Note: The code below is for JSS Technical Preview 4. The code for final release will be significantly simpler. I'll update this post and the repository on GitHub when it is made available.

Create Mock JSS Rocks API

The jssRocksApi created above requires a /jssrocksapi/form endpoint to be present on the server. Mon ami Jean-François L'Heureux has blogged how to create fake routes in disconnected mode and we can use this technique to handle both GET and POST for /jssrocksapi/form in disconnected mode.

Create jss-rocks-service.js in /scripts/disconnected-server:

let contact = {
  name: ""
};

class jssRocksService {
  static getContact(request, response) {
    response.json(contact);
  }

  static submitForm(request, response) {
    contact = (({ name }) => ({ name }))(request.body);
    response.sendStatus(200);
  }
}

module.exports = jssRocksService;

This is basically a mock of the Controller we'll make in connected mode.

Install and Configure csurf

Open the command line and install csurf:

yarn add csurf --dev

Create csrf-protection.js in /scripts/disconnected-server:

const csrf = require("csurf");

const antiForgeryTokenKey = "__RequestVerificationToken";
exports.antiForgeryTokenKey = antiForgeryTokenKey;

exports.csrfProtection = csrf({
  cookie: { key: antiForgeryTokenKey },
  value: request => request.body[antiForgeryTokenKey]
});

This creates the csurf middleware and configures it to use __RequestVerificationToken for the form and cookie names, just like ASP.NET.

Add jssRocksService to Disconnected Mode Proxy

Now we will register the jssRocksService with the disconnected mode proxy.

Pop open the command line and install a couple of packages in your repository: body-parser and cookie-parser. These are middleware for Express.js required by csurf to validate your anti-forgery tokens.

yarn add body-parser --dev
yarn add cookie-parser --dev

Open up /scripts/disconnected-mode-proxy.js. Add the following imports to the top:

const express = require("express");
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const { csrfProtection } = require("./disconnected-server/csrf-protection");
const jssRocksService = require("./disconnected-server/jss-rocks-service");

Just above proxyOptions, create a new instance of the Express.js server with body-parser and cookie-parser middleware configured:

const server = express();
server.use(bodyParser.urlencoded({ extended: true }));
server.use(cookieParser());

Add the following to the bottom of proxyOptions:

server,
afterMiddlewareRegistered: (app) => {
  app.get("/jssrocksapi/form", jssRocksService.getContact);
  app.post("/jssrocksapi/form", csrfProtection, jssRocksService.submitForm);
},

This tells the disconnected mode proxy to use the Express.js server we configured with the body-parser and cookie-parser middleware instead of creating its own. It tells the disconnected mode proxy to return the results of jssRocksService.getContact on a GET to localhost:3000/jssrocksapi/form. And finally, it tells the disconnected mode proxy to call into jssRocksService.submitForm on a POST to localhost:3000/jssrocksapi/form, but use csrfProtection to validate the anti-forgery token before processing the request, just like the MVC and Sitecore action filters would do.

Last, open up package.json in the root of your repository and add this to the proxy object under "/data/media":

"/jssrocksapi": {
  "target": "http://localhost:3042"
}

At this point, we've got a functional mock JSS Rocks API in place. Now we just need to tweak the disconnected mode Layout Service to provide an anti-forgery token to our JssRocksForm component.

Add Anti-forgery Token to Disconnected Layout Service

If you scroll back up to the JssRocksForm component, you'll notice that we're grabbing the anti-forgery token from this.props.rendering.antiForgeryToken. We can use the techniques I blogged here to add an anti-forgery token to the rendering data in the disconnected Layout Service.

Copy the contents of create-custom-disconnected-server.js from GitHub into your JSS app at /scripts/disconnected-server. This will expose an extension to the disconnected Layout Service, customizeRoute, that we can customize in the disconnected mode proxy.

Open /scripts/disconnected-mode-proxy.js and make the following changes to use the custom disconnected server:

  1. Change line 13 to const { createCustomDisconnectedServer } = require('./disconnected-server/create-custom-disconnected-server');.
  2. Add const getRouteRenderings = require('./layout-service/get-route-renderings'); under the jssRocksService import.
  3. Add const addAntiForgeryTokens = require('./layout-service/add-anti-forgery-tokens'); under the getRouteRenderings import.

In proxyOptions, right after afterMiddlewareRegistered, add the following:

customizeRoute: (route, rawRoute, currentManifest, request, response) => {
  const routeRenderings = getRouteRenderings(route);
  addAntiForgeryTokens(routeRenderings, currentManifest, request, response);
  return route;
}

When you're done disconnected-mode-proxy.js should look like this.

getRouteRenderings grabs all renderings for the current route as an array. Grab get-route-renderings.js from GitHub and add it under /scripts/layout-service.

Also under /scripts/layout-service, create add-anti-forgery-tokens.js:

const {
  csrfProtection,
  antiForgeryTokenKey
} = require("../disconnected-server/csrf-protection");

const getAntiForgeryToken = (request, response) => {
  csrfProtection(request, response, () => {});
  return {
    name: antiForgeryTokenKey,
    value: request.csrfToken()
  };
};

const addAntiForgeryTokens = (
  routeRenderings,
  currentManifest,
  request,
  response
) => {
  const afTokenRenderings = currentManifest.renderings
    .filter(r => r.addAntiForgeryToken)
    .map(r => r.name.toLowerCase());

  routeRenderings
    .filter(r => afTokenRenderings.includes(r.componentName.toLowerCase()))
    .forEach(
      r => (r.antiForgeryToken = getAntiForgeryToken(request, response))
    );
};

module.exports = addAntiForgeryTokens;

When we created the JssRocksForm component at the beginning of this post, we added addAntiForgeryToken: true to the manifest registration. This method uses that flag to determine which renderings in the route require an anti-forgery token in their rendering data.

getAntiForgeryToken calls the csurf middleware we configured in csrf-protection for two things: add the anti-forgery token cookie to the response and add the csrfToken() method to the request object. The request.csrfToken() method generates an anti-forgery token we can add to the Layout Service.

Now if you inspect the Layout Service response for a route with the JssRocksForm on it, you'll notice that it has an anti-forgery token as part of its rendering data:

{
  "uid": "{B9ED5A1D-E5B5-5C17-9294-128E72C0920C}",
  "componentName": "JssRocksForm",
  "dataSource": "available-in-connected-mode",
  "params": {},
  "fields": {},
  "antiForgeryToken": {
    "name": "__RequestVerificationToken",
    "value": "ooJY0iS4-4aDjDZoHkidleLjiAKmPkJjsIM8"
  }
}

If you fire up your JSS app with jss start, your form should be fully functional. After you load the page, clear your cookies and notice that form submission fails due to the missing anti-forgery token cookie--this is exactly how the MVC and Sitecore validators behave.

This takes care of anti-forgery tokens in disconnected mode. On to connected mode!

Connected Mode

In connected mode, you have several options to add anti-forgery tokens to the Layout Service. You can add them by extending the context data, by creating a custom Rendering Contents Resolver (RCR), or with a custom placeholder transformer.

I don't feel that context data is the right place to add anti-forgery tokens. If you add to context data, they're going to be added to every route. You're not going to need the anti-forgery token on every route, only on routes with forms.

I've blogged about my issues with Rendering Contents Resolvers before. All of the issues there I raised apply here. Anti-forgery tokens don't come from an item field in Sitecore, so I don't think they should be added to the fields property of your rendering. Your RCRs are also going to get out of hand as soon as you want to do additional route customizations.

I'm going to show you how to do this with a custom placeholder transformer. If you don't like that technique, that's fine. The code in the processor we'll create to emit the anti-forgery token will work just as well in an RCR. If you decide to create an RCR for this, your form components and disconnected mode code will need to look for the anti-forgery token in fields instead.

Going forward I'm going to assume that you have the PipelinePlaceholderTransformer in place from my blog post on extending connected Layout Service rendering data. You can grab the code here on GitHub.

Add Anti-forgery Token Json Rendering Field

In disconnected mode we used an addAntiForgeryToken flag in the manifest to determine which components need an anti-forgery token. We'll do this in Sitecore by adding a checkbox field to the Json Rendering template: Add Anti-forgery Token.

Add Anti-forgery Token checkbox field

Open the /sitecore/templates/JavaScriptServices/Json Rendering template in the Content Editor. Add a new section called Anti-forgery Token and add a checkbox field called Add Anti-forgery Token. Make note of the field's ID.

Add Anti-forgery Token to Layout Service

Create the following pipeline processor. The <transformPlaceholderElement /> and pipeline and supporting classes aren't available out of the box, so make sure to grab them first.

using System.Web;
using System.Web.Helpers;
using CoreySmith.Foundation.LayoutService.ItemRendering;
using CoreySmith.Foundation.LayoutService.Pipelines.LayoutService.TransformPlaceholderElement;
using HtmlAgilityPack;
using Sitecore.Data.Fields;

namespace CoreySmith.Feature.Forms.Pipelines.LayoutService.TransformPlaceholderElement
{
  public class AddAntiForgeryToken : TransformPlaceholderElementProcessor
  {
    public override void TransformPlaceholderElement(TransformPlaceholderElementPipelineArgs args)
    {
      if (!(args.Element is ExtensibleRenderedJsonRendering extensibleRendering)) return;
      if (!ShouldAddAntiForgeryToken(extensibleRendering)) return;

      var antiForgeryTokenHtml = AntiForgery.GetHtml();
      var (name, value) = ParseAntiForgeryToken(antiForgeryTokenHtml);
      args.Result.antiForgeryToken = new
      {
        name,
        value
      };
    }

    private static bool ShouldAddAntiForgeryToken(ExtensibleRenderedJsonRendering extensibleRendering)
    {
      var rendering = extensibleRendering?.Rendering?.RenderingItem?.InnerItem;
      if (rendering == null) return false;

      var addAntiForgeryTokenField = (CheckboxField)rendering.Fields[Templates.JsonRendering.Fields.AddAntiForgeryToken];
      return addAntiForgeryTokenField?.Checked ?? false;
    }

    private static (string Name, string Value) ParseAntiForgeryToken(HtmlString antiForgeryTokenHtml)
    {
      var doc = new HtmlDocument();
      doc.LoadHtml(antiForgeryTokenHtml.ToString());
      var input = doc.DocumentNode.SelectSingleNode("//input");
      return (input.Attributes["name"].Value, input.Attributes["value"].Value);
    }
  }
}

Templates.JsonRendering.Fields.AddAntiForgeryToken is the field ID of your Add Anti-forgery Token field on the Json Rendering template.

If the Add Anti-forgery Token checkbox for the current rendering is checked, this processor uses the AntiForgery.GetHtml method from System.Web.WebPages to generate an anti-forgery token. This is exactly the same method that the @Html.AntiForgeryToken() HTML helper in MVC uses under the hood.

AntiForgery.GetHtml adds the anti-forgery token cookie to the response and returns an <input /> field with the form token. We use HtmlAgilityPack to parse the token out of the <input /> field and add it to the rendering data.

Patch the processor in to the <transformPlaceholderElement /> pipeline:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <group groupName="layoutService">
        <pipelines>
          <transformPlaceholderElement>
            <processor type="CoreySmith.Feature.Forms.Pipelines.LayoutService.TransformPlaceholderElement.AddAntiForgeryToken, CoreySmith.Feature.Forms">
              <allowedConfigurations hint="list">
                <config desc="jss">jss</config>
              </allowedConfigurations>
            </processor>
          </transformPlaceholderElement>
        </pipelines>
      </group>
    </pipelines>
  </sitecore>
</configuration>

That's it. Not so bad 😊. As I mentioned before, this same code would work just as well in a Rendering Contents Resolver if that's the route you want to take.

Create JSS Rocks API

You can create your JSS Rocks API with either an MVC or Web API controller.

The following class models data from the form:

namespace CoreySmith.Feature.Forms.Models
{
  public class JssRocksFormData
  {
    [Required]
    public string Name { get; set; }
  }
}

The [Required] attribute works with both MVC and Web API's model binders.

MVC

With MVC, your controller could look like this:

using System.Net;
using System.Web.Mvc;
using CoreySmith.Feature.Forms.Models;
using Newtonsoft.Json;
using Sitecore.LayoutService.Mvc.Security;

namespace CoreySmith.Feature.Forms.Controllers
{
  [EnableApiKeyCors]
  public class JssRocksFormController : Controller
  {
    private static string _name;

    [HttpGet]
    public ActionResult Form()
    {
      var model = new { name = _name };
      return Content(JsonConvert.SerializeObject(model), "application/json");
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Form(JssRocksFormData form)
    {
      if (!ModelState.IsValid) return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

      _name = $"{form.Name}";
      return new HttpStatusCodeResult(HttpStatusCode.OK);
    }
  }
}

You'll use System.Web.Mvc.ValidateAntiForgeryTokenAttribute on your Controller actions to validate your anti-forgery tokens.

The [EnableApiKeyCors] filter on the Controller comes from Sitecore.LayoutService.Mvc and uses the CORS settings from your API key (?sc_apikey in the API URL configured at the beginning of this post) in the core database to handle CORS requests. Without this, your anti-forgery token cookie won't be accepted by the server in connected mode.

Don't forget to register your Controller with Sitecore's container, register a route for your controller, and patch the route in to the <initialize /> pipeline.

Web API

With Web API, your controller could look like this:

using System.Web.Http;
using CoreySmith.Feature.Forms.Models;
using Sitecore.Web.Http.Filters;

namespace CoreySmith.Feature.Forms.Controllers
{
  public class JssRocksFormApiController : ApiController
  {
    private static string _name;

    [HttpGet]
    public IHttpActionResult Get()
    {
      var model = new { name = _name };
      return Json(model);
    }

    [HttpPost]
    [ValidateHttpAntiForgeryToken]
    public IHttpActionResult Post(JssRocksFormData form)
    {
      if (!ModelState.IsValid) return BadRequest();

      _name = $"{form.Name}";
      return Ok();
    }
  }
}

You'll use Sitecore.Web.Http.Filters.ValidateHttpAntiForgeryTokenAttribute on your ApiController actions to validate your anti-forgery tokens.

Sitecore has some built-in infrastructure for Web API that works with your API key (?sc_apikey in the API URL configured at the beginning of this post), however, it doesn't handle cookies properly in connected mode. To fix this, grab this SupportsCredentialsCorsPolicyProvider from GitHub and register it with the Sitecore container. Without this, you will get CORS errors in connected mode.

Don't forget to register your ApiController with Sitecore's container, register a route for your controller, and patch the route in to the <initialize /> pipeline.

Conclusion

In this post I've walked you through how extend the Layout Service to leverage ASP.NET MVC's anti-forgery tokens.

In disconnected mode, we used csurf to implement anti-forgery tokens with Express.js using the same form and cookie names as the ASP.NET anti-forgery implementation so that you can be sure your components are sending the necessary data to your server once you move your JSS app to connected mode.

In connected mode, we used the same underlying infrastructure as the @Html.AntiForgeryToken() HTML helper to generate an anti-forgery token and send it to your JSS app through the Layout Service. This works with the MVC and Sitecore anti-forgery token validators to provide robust CSRF mitigation. The only limitation is that the MVC and Sitecore anti-forgery token validators require data be sent to the server as application/x-www-form-urlencoded--if you want to post any other content type such as application/json, you'll need to build your own validators.

Let me know your thoughts on this approach in the comments.