Background
To secure websites from cross-site request forgery (CSRF, or XSRF) attack, ASP.NET MVC provides an excellent mechanism:
The server prints tokens to cookie and inside the form;
When the form is submitted to server, token in cookie and token inside the form are sent by the HTTP request;
Server validates the tokens.
To print tokens to browser, just invoke HtmlHelper.AntiForgeryToken():<% using (Html.BeginForm())
{ %>
<%: this.Html.AntiForgeryToken(Constants.AntiForgeryTokenSalt)%>
<%-- Other fields. --%>
<input type="submit" value="Submit" />
<% } %>
which writes to token to the form:<form action="..." method="post">
<input name="__RequestVerificationToken" type="hidden" value="J56khgCvbE3bVcsCSZkNVuH9Cclm9SSIT/ywruFsXEgmV8CL2eW5C/gGsQUf/YuP" />
<!-- Other fields. -->
<input type="submit" value="Submit" />
</form>
and the cookie:
__RequestVerificationToken_Lw__=J56khgCvbE3bVcsCSZkNVuH9Cclm9SSIT/ywruFsXEgmV8CL2eW5C/gGsQUf/YuP
When the above form is submitted, they are both sent to server. [ValidateAntiForgeryToken] attribute is used to specify the controllers or actions to validate them:[HttpPost]
[ValidateAntiForgeryToken(Salt = Constants.AntiForgeryTokenSalt)]
public ActionResult Action(/* ... */)
{
// ...
}
This is very productive for form scenarios. But recently, when resolving security vulnerabilities for Web products, I encountered 2 problems:
It is expected to add [ValidateAntiForgeryToken] to each controller, but actually I have to add it for each POST actions, which is a little crazy;
After anti-forgery validation is turned on for server side, AJAX POST requests will consistently fail.
Specify validation on controller (not on each action)
Problem
For the first problem, usually a controller contains actions for both HTTP GET and HTTP POST requests, and usually validations are expected for HTTP POST requests. So, if the [ValidateAntiForgeryToken] is declared on the controller, the HTTP GET requests become always invalid:[ValidateAntiForgeryToken(Salt = Constants.AntiForgeryTokenSalt)]
public class SomeController : Controller
{
[HttpGet]
public ActionResult Index() // Index page cannot work at all.
{
// ...
}
[HttpPost]
public ActionResult PostAction1(/* ... */)
{
// ...
}
[HttpPost]
public ActionResult PostAction2(/* ... */)
{
// ...
}
// ...
}
If user sends a HTTP GET request from a link: http://Site/Some/Index, validation definitely fails, because no token is provided.
So the result is, [ValidateAntiForgeryToken] attribute must be distributed to each HTTP POST action in the application:public class SomeController : Controller
{
[HttpGet]
public ActionResult Index() // Works.
{
// ...
}
[HttpPost]
[ValidateAntiForgeryToken(Salt = Constants.AntiForgeryTokenSalt)]
public ActionResult PostAction1(/* ... */)
{
// ...
}
[HttpPost]
[ValidateAntiForgeryToken(Salt = Constants.AntiForgeryTokenSalt)]
public ActionResult PostAction2(/* ... */)
{
// ...
}
// ...
}
Solution
To avoid a large number of [ValidateAntiForgeryToken] attributes (one attribute for one HTTP POST action), I created a wrapper class of ValidateAntiForgeryTokenAttribute, where HTTP verbs can be specified:[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = false, Inherited = true)]
public class ValidateAntiForgeryTokenWrapperAttribute : FilterAttribute, IAuthorizationFilter
{
private readonly ValidateAntiForgeryTokenAttribute _validator;
private readonly AcceptVerbsAttribute _verbs;
public ValidateAntiForgeryTokenWrapperAttribute(HttpVerbs verbs)
: this(verbs, null)
{
}
public ValidateAntiForgeryTokenWrapperAttribute(HttpVerbs verbs, string salt)
{
this._verbs = new AcceptVerbsAttribute(verbs);
this._validator = new ValidateAntiForgeryTokenAttribute()
{
Salt = salt
};
}
public void OnAuthorization(AuthorizationContext filterContext)
{
string httpMethodOverride = filterContext.HttpContext.Request.GetHttpMethodOverride();
if (this._verbs.Verbs.Contains(httpMethodOverride, StringComparer.OrdinalIgnoreCase))
{
this._validator.OnAuthorization(filterContext);
}
}
}
When this attribute is declared on controller, only HTTP requests with the specified verbs are validated:[ValidateAntiForgeryTokenWrapper(HttpVerbs.Post, Constants.AntiForgeryTokenSalt)]
public class SomeController : Controller
{
// Actions for HTTP GET requests are not affected.
// Only HTTP POST requests are validated.
}
Now one single attribute on controller turns on validation for all HTTP POST actions.
Submit token via AJAX
Problem
For AJAX scenarios, when request is sent by JavaScript instead of form:$.post(url, {
productName: "Tofu",
categoryId: 1 // Token is not posted.
}, callback);
This kind of AJAX POST requests will always be invalid, because server side code cannot see the token in the posted data.
Solution
The token must be printed to browser then submitted back to server. So first of all, HtmlHelper.AntiForgeryToken() must be called in the page where the AJAX POST will be sent.
Then jQuery must find the printed token in the page, and post it:$.post(url, {
productName: "Tofu",
categoryId: 1,
__RequestVerificationToken: getToken() // Token is posted.
}, callback);
To be reusable, this can be encapsulated in a tiny jQuery plugin:(function ($) {
$.getAntiForgeryToken = function () {
// HtmlHelper.AntiForgeryToken() must be invoked to print the token.
return $("input[type='hidden'][name='__RequestVerificationToken']").val();
};
var addToken = function (data) {
// Converts data if not already a string.
if (data && typeof data !== "string") {
data = $.param(data);
}
data = data ? data + "&" : "";
return data + "__RequestVerificationToken=" + encodeURIComponent($.getAntiForgeryToken());
};
$.postAntiForgery = function (url, data, callback, type) {
return $.post(url, addToken(data), callback, type);
};
$.ajaxAntiForgery = function (settings) {
settings.data = addToken(settings.data);
return $.ajax(settings);
};
})(jQuery);
Then in the application just replace $.post() invocation with $.postAntiForgery(), and replace $.ajax() instead of $.ajaxAntiForgery():$.postAntiForgery(url, {
productName: "Tofu",
categoryId: 1
}, callback); // Token is posted.
This solution looks hard coded and stupid. If you have more elegant solution, please do tell me.