Jump to content

CSRF problem when AJAX submission


Recommended Posts

I am implementing the ability to handle form submission using AJAX. The problem I have is that even though, as far as I can tell, I convert the AJAX-submitted JSON input into the equivalent of $input->post. When I call $form->processInput() it always throws that it appears to be forged.


if ($config->ajax && $_SERVER['REQUEST_METHOD'] === 'POST') {
    // get the file body and decode the JSON.
    try {
        $body = file_get_contents('php://input');
        $json = wireDecodeJSON($body);
        //wire('log')->save('info', 'ajax-json:' . print_r($json, true));
        wire('log')->save('info', 'json-data:' . print_r($json['data'], true) . ' sid: ' . session_id());
        $fakeinput = new WireInputData($json['data']);
        $form = deleteRequest('xyzzy');

    } catch (Exception $e) {
        echo json_encode(array('reason' => $e->getMessage()));


The log shows that the TOKEN and the TOKEN value are the same as when a normal form is submitted (I have both on the page for testing and can submit via normal POST as well as via AJAX). The session_id() value is the same. What am I missing?


Log entries ("dumb" is the normal submit button name, "fake-text" is an empty text field, "submit" is the AJAX submit button name.)

(using form-post): post Array ( [dumb] => dumb-button [TOKEN1649939534X1479234443] => f4VLZ17RlXfp9KVCQr/GIhoZ3krbuWK5 )

(using ajax-post): json-data: Array ( [fake-text] => [submit] => DELETE ENDUSERS [TOKEN1649939534X1479234443] => f4VLZ17RlXfp9KVCQr/GIhoZ3krbuWK5 ) 

Edited by bmacnaughton
Typos and clarification
Link to comment
Share on other sites

OK, I think I know what the problem is.

SessionCSRF.hasValidToken() doesn't use the argument passed to $form->processInput() to get the CSRF token. It references $input->post no matter what was passed to processInput(). And with an AJAX request $input->post is empty; it seems like it should use the argument passed to $form->processInput(). (Actually, it checks for extended HTTP headers when it's an AJAX request but if they aren't present it then directly references $input->post.)

The solution is to set an HTTP header X-tokenname where tokenname is the CSRF token name and the value for the header is the CSRF token value.

It's odd the $form->processInput($arg) does take field values from $arg but it doesn't take the token from $arg and instead directly references $input->post.

Edited by bmacnaughton
Clarification/additional information
  • Like 3
Link to comment
Share on other sites

  • 5 months later...
11 hours ago, hettiger said:

Is there really no solution to this issue? It's a huge drawback thought :-/

That's how php is supposed to work, and processwire does not deviate from that. 


$_POST: An associative array of variables passed to the current script via the HTTP POST method when using application/x-www-form-urlencoded or multipart/form-data as the HTTP Content-Type in the request.


If one does need a different behaviour it's their own responsibility. And to be honest, if one is using processwire's form api with custom ajax sending, then it shouldn't be that hard to build that custom ajax handler to send the form data as application/x-www-form-urlencoded or multipart/form-data.

  • Like 1
Link to comment
Share on other sites

5 hours ago, LostKobrakai said:

That's how php is supposed to work, and processwire does not deviate from that

What's odd is that PW doesn't take all the data from the array passed to $form->process().  You can argue the CSRF protection is a different function, but that's just internal implementation. And it's easy enough to stuff things into $input->post, so it's not like it prevents forging.

Link to comment
Share on other sites

Thank you guys for the additional information. What I ended up is posting data like this:

private drop(event: DragEvent): void {
    let payload = JSON.parse(event.dataTransfer.getData("application/json"));
    let widget = this.$root as any;
    let data = new URLSearchParams();

    // ProcessWire CSRF protection does not recognize JSON data per default
    data.append(widget.tokenName, widget.tokenValue);
    data.append('foo', payload.foo);

    fetch("/admin/page/foo/bar/", {
        headers: {
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "X-Requested-With": "XMLHttpRequest" // Required for ProcessWire to identify as ajax request
        method: "post",
        credentials: "same-origin",
        body: data
    .then(response => response.text())
    .then(text => console.log(text))
    .catch(error => console.error(error));

This request is perfectly recognized by ProcessWire the way you would expect and CSRF protection is working.



If one does need a different behaviour it's their own responsibility. And to be honest, if one is using processwire's form api with custom ajax sending, then it shouldn't be that hard to build that custom ajax handler to send the form data as application/x-www-form-urlencoded or multipart/form-data.

It shouldn't be tedious to make use of security critical features like CSRF protection.

I love ProcessWire because it saves me huge amounts of time. If I have to mess around with something to make it work that costs me time. It doesn't matter if you consider it to be an easy task or if it's the developer's responsibility. It takes time and there's room for improvements. I'm just giving feedback. Take it or leave it.

  • Like 2
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

  • Recently Browsing   0 members

    No registered users viewing this page.

  • Similar Content

    • By Liam88
      I'm really struggling with this as it's something not in my wheelhouse. I'm creating a blog style page (a grid of cards) which has attributes.
      I have a snip of javascript which grabs values from checkboxes which are put into a value like the below:
      document.querySelector("form").onsubmit=ev=>{ ev.preventDefault(); let o={}; ev.target.querySelectorAll("[name]:checked").forEach(el=>{ (o[el.name]=o[el.name]||[]).push(el.value)}) console.log(location.pathname+"?"+ Object.entries(o).map(([v,f])=> v+"="+f.join("_")).join("&") ); document.location.href = location.pathname+"?"+ Object.entries(o).map(([v,f])=> v+"="+f.join("_")).join("&"); } As I'm currently refeshing the page on button click with those values the end result includes the location but can easily remove this.
      I then use this value in "input->get" to get the values which I then append to a find() rule. See code below:
      $selector = "template='adbank_pages',sort=published,include=all,status!=hidden"; // Get the channel and content inputs $channel = $input->get->channel; $content = $input->get->content; if($channel){ // Grab the channel string, explode into an array for checkbox checking and then replace _ with | to create or rules in the selector. $chanArray = explode("_", $channel); $chan = $channel = str_replace('_', '|', $channel); $selector = $selector .= ",ab_channels=$chan"; } if($content){ // Grab the content string, explode into an array for checkbox checking and then replace _ with | to create or rules in the selector. $contArray = explode("_", $content); $cont = $content = str_replace('_', '|', $content); $selector = $selector .= ",ab_content=$cont"; } if($input->get){ // If a valid input result $all = $pages->find($selector); } }else{ // If no input show them all $all = $page->children("template='adbank_pages',sort=-published,include=all,status!=hidden"); } $items = $all->find("limit=12"); // Limit the output and use pagination As mentioned above I currently refresh the page to adjust the $selector filter within the $all with a fallback $all if there are no results.
      I know I need to use AJAX to filter the content without refresh but I am really struggling with the set up. I have read multiple posts including the original by Ryan but still confused.
      If anyone can direct/help on this it would be appreciated.
      Thank you
    • By 999design
      Hi all,
      Running into an odd error that I can't seem to get my head around.
      We have 2 separately created Formbuilder forms sitting on a single page.
      But we keep experiencing weird results with them, originally we couldn't get one of the forms to ever submit so we ended up disabling CSRF for them which let us get around this issue.
      However it then causes a problem in that with CSRF disabled, one of forms always records 2 entries on submission. Just a straight duplicate within the entries for that form.
      So trying to stop this happening we tried enabling CSRF again and although that does stop the duplicated entry, it ends up giving really weird feedback such as the attached screengrab.
      Hazarding a guess I assume whatever is trigger on submission is firing twice because of the presence of the second form, but I have no idea why this would be the case as they are 2 seperately named forms?
      Any ideas?

    • By ICF Church
      Hi 👋
      Anyone else having this problem?
      - Repeater (matrix & normal) with mutlilanguage fields (text, textarea…) 
      - Backend language set to something other than default (ie. German) 
      - Add a new repeater Item (ajax, I found no way to possible to disable it with matrix)

      (Notice how the default language tab is active instead of the backend language…)
      - Write something into the (default language) field
      - Try to save, if field is required, this will not work. If not required, then when reloading, the content will be inside the backend language field, instead of the default language field who was (presumably) active
      When  loading  a new repeater element with ajax, the default langue tab is active, but the backend language inputfield is visible (with no visual indication). When writing into the field, it will populate the backend language. When manually clicking on the default language tab (which is already active), the field will switch to the actual default language field (which is [now] empty) (that can now be populated…)
      Also Notice, the labels of the elements to be added are in default language as well instead of the translated label (images instead of Bilder)…
      ProcessWire 3.0.148, Profields 0.0.5…
      Is it my system configuration, or does anyone else have the same issue? This is a screen recording of the problem:
      Issue: https://github.com/processwire/processwire-issues/issues/1179

      Screen Recording 2020-02-25 at 14.18.31.mov
    • By benbyf
      I have a a form in my site footer that can be accessed anywhere on site, I've added the form in the _inc.php file and added the render in the pages footer.php. However, this works well on the homepage e.g. you can submit said form and get a thank you on reload, doesnt work at all on other pages... Just lots like a fresh reload. Any thing im doing wrong here or ways to diagnose as there isn't an error log for formbuilder etc...?
    • By Peter Knight
      I have a few web forms which require testing on a weekly basis and I don't want the recipients (administrators) to receive these test emails.
      What would be a good way to test approx 15 forms from the front end and have the test delivered a list of secondary administrator recipients?
      I'm thinking that I could have some kind of config file which watches for a trigger word or email and then understands that it's a test and to bypass the normal admins?
      All of the forms ask for an email address so I could setup an email such as 'testform@email.not' etc which my config file (hook?) would watch for.
      Or is there a better way to do this?
      Additionally, I have a few extra requirements...
      Forms should goto an alternative success page. This is because I don't want my test to skew my Google Analytics conversion tracking Forms would need to be tested from the front-end and not the PW admin area Any advice appreciated.
      BTW I realise this should be posted in the proper FormBuilder support forum. I am in the process of renewing my license for access to that support forum.
  • Create New...