Search code examples
c#asp.net-corerazor-pagesantiforgerytoken

Correct handling of formless AJAX POST and AntiForgery in C# Razor Pages


In this hypothetical scenario, I am displaying a list of Messages on a page.

A snip of javascript - Edit(id) - retrieves from the server the correct HTML for editing a message, and injects it on the page.

Another snip of javascript - Save(id) - does an AJAX POST back to the server and injects the result back on the page.

According to the AntiForgery rules of Razor Pages, the POST triggers a 404 because it's missing the correct token. Since I'm not using a <form>, the built-in antiforgery framework doesn't kick in automatically and provide the token.

And, since I have the option of, in theory, editing multiple message in parallel, I'm not sure how to correctly implement a @Html.AntiForgeryToken() field. Do I generate a token per message, and then somehow scoop it up and send it along with my POST, or...?

I'm wondering if there is a correct way of handling this scenario that takes AntiForgery into account, or if I just have to suck it up and build around it? I can work around the problem easily enough with [IgnoreAntiforgeryToken] but this obviously has its own consequences.

For the time being, MessageManager.Messages is just a static List<TextMessage>

View.cshtml

@foreach (var item in Model.Messages) {
    <div id="[email protected]">
        <label class="form-label">@item.Type:</label>
        @{
            var result = Markdown.ToHtml(item.Message, pipeline);
            @Html.Raw(result)
        }
    </div>
    <div class="mb-3 text-end">
        <a href="#" onclick="Edit(@item.Id)">✏</a>
        <a href="#" onclick="Delete(@item.Id)">❌</a>
    </div>
}


@section Scripts {
    <script>
        function Edit(messageId) {
            $.get(
                "/Messages/_EditMessage?messageId=" + messageId,
                function (str) {
                    $('#message_' + messageId).html(str);
                }
            );
        }
        function Save(messageId) {
            $.post(
                "/Messages/_EditMessage?messageId=" + messageId,
                { NewMessage: $('#NewMessage_' + messageId).val() },
                function (str) {
                    $('#message_' + messageId).html(str);
                }
            );
        }
    </script>
}

_EditMessage.cshtml

@if (Model.Edit) {
    <div class="mb-3">
        <label class="form-label">@Model.Message.Type</label>
        <textarea class="form-control" id="[email protected]" rows="@(Model.Message.Message.Count(c => c == '\n') + 1)">@Model.Message.Message</textarea>
    </div>
    <div>
        <button class="btn btn-outline-success" onclick="Save(@Model.Message.Id)">Save</button>
    </div>
}
else {
    var result = Markdown.ToHtml(Model.Message.Message, pipeline);

    <label class="form-label">@Model.Message.Type:</label>
    @Html.Raw(result)
}

_EditMessage.cshtml.cs

[IgnoreAntiforgeryToken]
public class _EditMessageModel : PageModel
{
    public TextMessage Message { get; set; } = null!;
    public bool Edit { get; set; }

    [BindProperty] public string NewMessage { get; set; } = string.Empty;

    public IActionResult OnGet(int messageId) {
        var message = MessageManager.Messages.FirstOrDefault(m => m.Id == messageId);
        if (message is null)
            return NotFound(messageId);

        Message = message;
        Edit = true;

        return Page();
    }

    public IActionResult OnPost(int messageId) {
        var message = MessageManager.Messages.FirstOrDefault(m => m.Id == messageId);
        if (message is null)
            return NotFound(messageId);

        Message = message;
        Message.Message = NewMessage;
        Edit = false;

        return Page();
    }
}

Conclusion

It really was that simple.

Add @Html.AntiForgeryToken() to the bottom of the view page, and replace the Save() function with

function Save(messageId) {
    $.ajax({
        type: "POST",
        headers: { "RequestVerificationToken": $('input[name="__RequestVerificationToken"]').val() },
        url: "/Messages/_EditMessage?messageId=" + messageId,
        data: { NewMessage: $('#NewMessage_' + messageId).val() }
    }).done(function(response){
        $('#message_' + messageId).html(response);
    });
}

It doesn't seem to complain about re-using the same token to submit multiple POSTs.


Solution

  • Do I generate a token per message, and then somehow scoop it up and send it along with my POST, or...?

    This question is split into two parts to avoid confusion as there's a 'yes' part and a 'no' part in the answer.

    Do I generate a token per message...

    No, you do not need to generate a token per message. The HTML Helper @Html.AntiForgeryToken() needs to be added once in the body of a Razor view and the token generated by the AntiForgeryToken Helper can be reused on the same page.

    If the table in your View.cshtml sample was contained within a <form method="post"></form> element, the FormTagHelper adds the hidden anti-forgery field. Your View.cshtml sample does not have a form element.

    ...and then somehow scoop it up and send it along with my POST?

    Yes, you need to retrieve the token generated by @Html.AntiForgeryToken() and add it to each POST request made from the client-side page via JavaScript/AJAX.

    Per this page on learnrazorpages.com:

    If you omit the value from the request, the server will return a 400 Bad Request result.

    This SO answer provides a function to call in the save() function from your question. On post, the function retrieves the anti-forgery token from the hidden input field created by @Html.AntiForgeryToken(). Add @Html.AntiForgeryToken() in the body of your View.cshtml page.

    For simplicity, the token is retrieved on every call to the save() function. You can, however, call the addAntiForgeryToken() function once in JavaScript, store its value, and then append the token value on each subsequent post request.

    @section Scripts {
        <script>
            function Edit(messageId) {
                $.get(
                    "/Messages/_EditMessage?messageId=" + messageId,
                    function (str) {
                        $('#message_' + messageId).html(str);
                    }
                );
            }
            function Save(messageId, content) {
                let data = { NewMessage: messageId };
                data = addAntiForgeryToken(data);
                console.log(data);
                $.post(
                    "/Messages/_EditMessage?messageId=" + messageId,
                    data,
                    function (str) {
                        $('#message-response').html(str);
                    }
                );
            }
    
            // CSRF (XSRF) security
            // https://stackoverflow.com/questions/73037094/how-to-do-an-ajax-post-with-mvc-antiforgerytoken
            function addAntiForgeryToken(data) {
                //if the object is undefined, create a new one.
                if (!data) {
                    data = {};
                }
                //add token
                const tokenInput = $('input[name=__RequestVerificationToken]');
                if (tokenInput.length) {
                    data.__RequestVerificationToken = tokenInput.val();
                }
                return data;
            }
        </script>
    }