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:
- Add an anti-forgery token to forms in your Razor views with the
@Html.AntiForgeryToken()
HTML helper. - 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 ApiController
s.
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 }));
}
};
getFormData = form => {
const formData = new FormData(form);
const { antiForgeryToken } = this.props.rendering;
formData.append(antiForgeryToken.name, antiForgeryToken.value);
return formData;
};
submitForm = event => {
event.preventDefault();
const formData = this.getFormData(event.target);
jssRocksApi
.submitForm(formData)
.then(response => {
if (!response.ok) {
throw new Error("Uh oh.");
}
this.setState({ name: formData.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 getFormData
, 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 post on extending rendering data.
Register the component with the JSS manifest at /sitecore/definitions/components/JssRocksForm.sitecore.js
:
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 few things to pay attention to in the submitForm
method.
First, you must set credentials: include
(or 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.
We're also not using the dataFetcher
helper that comes with the React sample app. The dataFetcher
uses Axios to make XHR requests (fetch
requests, on the other hand, do not). The Sitecore [ValidateHttpAntiForgeryToken]
action filter requires that the anti-forgery token input value be provided in a header for XHR requests instead of as part of the form data. To maintain feature parity between MVC and Web API, fetch
is the way to go. If you're not using Web API, feel free to use the dataFetcher
instead of fetch
. Or modify the dataFetcher
to use fetch
instead of Axios.
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]
/[ValidateHttpAntiForgeryToken]
action filters.
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
:
npm install csurf --save-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.
npm install body-parser --save-dev
npm install cookie-parser --save-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 /src/setupProxy.js
in the root of your repository and add this in the if (isDisconnected)
branch:
app.use(proxy('/jssrocksapi', { target: proxyUrl }));
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.
Open /scripts/disconnected-mode-proxy.js
and add the following under the jssRocksService
import:
const addAntiForgeryToken = require('./layout-service/add-anti-forgery-token');
In proxyOptions
, right after afterMiddlewareRegistered
, add the following:
customizeRendering: (transformedRendering, rawRendering, currentManifest, request, response) => {
let customizedRendering = addAntiForgeryToken(transformedRendering, currentManifest, request, response);
return customizedRendering;
}
⚠️Note:⚠️ your application must be using JSS 11.0.2
for this to work. The currentManifest
, request
, and response
parameters are not available to the customizeRendering
method in previous versions.
When you're done disconnected-mode-proxy.js
should look like this.
Create add-anti-forgery-token.js
in the /scripts/layout-service
folder:
const {
csrfProtection,
antiForgeryTokenKey
} = require("../disconnected-server/csrf-protection");
const getAntiForgeryToken = (request, response) => {
csrfProtection(request, response, () => {});
return {
name: antiForgeryTokenKey,
value: request.csrfToken()
};
};
const addAntiForgeryToken = (
transformedRendering,
currentManifest,
request,
response
) => {
const manifestRendering = currentManifest.renderings.find(
r => r.name == transformedRendering.componentName
);
if (!manifestRendering.addAntiForgeryToken) return undefined;
return {
...transformedRendering,
antiForgeryToken: getAntiForgeryToken(request, response)
};
};
module.exports = addAntiForgeryToken;
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
to do 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--this is basically the @Html.AntiForgeryToken()
input value.
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 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
.
Open the /sitecore/templates/Foundation/JavaScript Services/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.