RobertSF
RobertSF

Reputation: 528

Again, stopping back button form resubmission with nonce

Folks, I know this topic has been hashed to death, but after reading many answered questions here, I'm no closer to a solution.

The problem: after submitting a form, even with Post-Redirect-Get, users can still press the back button to return to the original form, which will be exactly as it was when posted. Users can then just press the submit button to send the same data again.

I realize it's bad practice to try to disable the back button, so I would like either: 1.- to be able to clear the form so that the data the user entered is no longer there when returning to the posted form via back button. 2.- to be able to distinguish in code between original form submission and repeat form submission.

I've been reading that you can accomplish #2 with a random number. In Stop data inserting into a database twice I read

I use nonces ("number used once") for forms where resubmission is a problem. Create a random number/string, store it in the session data and add it to the form as a hidden field. On form submission, check that the POSTed value matches the session value, then immediately clear the session value. Resubmission will fail. (James Socol)

Seem clear enough but I can't seem to get that to work. Here's skeletal code, as brief as I can make it. I've single-stepped through it. When I press the back button, the PHP code starts executing from the start. $_SERVER['REQUEST_METHOD'] returns GET so it's as if the form has not been posted, and the code falls through and generates a new random number. When the form is submitted (the second time), $_SESSION['rnd'] equals $_POST['rnd'] so the resubmitted form is processed as if it were the first time.

<?php
  session_start();
  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    if ($_SESSION['rnd'] == $_POST['rnd']) {
      $form_data = $_POST; //process form
      $_SESSION['rnd'] = 0;
      $msg = 'data posted';
    }
    else {
      $msg = 'cannot post twice';
    }
  }
  else {
    $msg = 'ready to post';
    $rnd = rand(100,999);
    $_SESSION['rnd'] = $rnd;
  }
?>
<html>
  <head>
    <title>app page</title>
  </head>
  <body>
  <h1>app page</h1>
  <h3><?= $msg ?></h3>
  <form method="post">
    Field 1: <input type="text" name="field1"
      value="<?= (isset($form_data['field1']) ? $form_data['field1'] : '') ?>">
    <input type="submit" name="submit" value="Ok">
    <input type="hidden" name="rnd" value="<?= (isset($rnd) ? $rnd : $_POST['rnd']) ?>">
  </form>

  </body>
</html>

Thanks for any insight. I realize the above code does not implement PRG, but I think it doesn't matter. The issue isn't an F5 refresh but a back button refresh.

Upvotes: 1

Views: 1688

Answers (3)

RobertSF
RobertSF

Reputation: 528

I look forward to Ryan Vincent's answer, but in the interim, after much hair pulling, here's the code I've devised that handles this situation. To recap, we're talking about preventing duplicate form submission. However, the circumstances make this case somewhat out of the ordinary.

The application is a survey of 150 questions that a professor has his students take at the start and end of the semester. The surveys are anonymous, and students may take them anywhere there's internet access, but many will take the survey at the school's computer lab, where several students taking the survey may gather around the same computer.

Anyone with the URL to the survey can take the survey, but we don't worry about non-students taking it. It's hard enough to get the students to take it, but we don't want students, as a prank, to be able to use the back button to return to the form with all its data still there and submit it again and again and again. On the other hand, it would be perfectly valid for two students to take the survey, one after the other, and submit identical responses. Yet because the surveys are anonymous, we can't distinguish between one student and another, though given the length of the survey, we're not worried about the same student filling a survey out twice.

In pseudo-ish code with a PHP flavor, here's what I came up with.

// application.php
start_session()
if form_posted
  validate_form
  if no_errors
    save_form
    $_SESSION['form_status'] = 'closed'
    header ('Location: confirmation.php')
  else
    //errors, so redisplay form
    $caller = 'process'
    include ('form.php')
  endif
else //form not posted
  if (!isset($_SESSION['form_status']))
    //first time displaying form
    $_SESSION['form_status'] = 'open'
    $caller = 'process'
    include ('form.php')
  elsif $_SESSION['form_status'] == 'open'
    //user refreshed form before submitting it
    unset ($_SESSION['form_status'])
    header ('Location: application.php')
  elsif $_SESSION['form_status'] == 'closed'
    //user pressed back button from confirmation_page
    header ('Location: confirmation.php')
  endif
endif

//confirmation.php
$caller = 'confirmation'
include ('form.php')

//form.php
<form>
  fields
  fields
  fields
  //only show submit button when $caller == 'process'
  if $caller == 'process'
    show_submit_button
  elseif $caller == 'confirmation'
    link ('click to fill out another form', return.php)
  endif
</form>

//return.php
session_start()
unset_session_variables
header('Location: application.php');

As with all scripts that post to themselves, the script is running either because the form as posted or for some other reason. If the form was posted, validate it, save the data, mark the form "closed," and redirect to a confirmation page.

If the form was not posted, there could be several reasons why we're here. One reason is that this is the start of a form cycle, first time through. In that case, mark the form 'open' and show the form. Another reason is that the user, while filling out the form, refreshed the form. In that case, reset the form status and let the script think it's the first-time run. But if the form status is 'closed,' that can only mean that the user was staring at the confirmation page and pressed the back button. In that case, we simply return the user to the confirmation page.

The confirmation page has a link to open a fresh form. That link resets the values in the session and then redirects to the main application script, which then acts as if it's the first time through. If a user submits the form and takes no action at the confirmation page but instead goes to Yahoo! or YouTube, the next user who tries to take the survey will get a confirmation page showing the data the earlier user entered. This is not a problem, as this next user can use the link in the confirmation page to generate a clean, fresh form.

Upvotes: 0

Ryan Vincent
Ryan Vincent

Reputation: 4513

This is not an answer yet - but is so i can post points and delete and change 'em.

RobertSF: i am working on an answer for this as i have to understand what to do in these circumstances.

I have had some sleep and am having a coffee.... :-)

I think the 'browser back button' use is a 'red-herring'.

If we state the 'requirements' of the program then we should be able to see what the issues are and what we can do about them.

1) Once the data has been validated and accepted from the 'form' then it becomes 'read ony'. This is not that unusual a requirement.

2) There is no 'obvious' key in the data accepted from the form so we have to we have to generate a 'unique' key for each 'form full' of data. Also each 'form full' of data is to be considered as an 'event'.

3) A user can enter the same data as we already have but if entered under a different 'event' than it must be accepted.

However, if we can write some code that enforces those three requirements then the 'browser back button and resubmission' of data we already have accepted once should not be a problem. It will be reported as already being on the database.

So, how do we do this?

We have to generate 'event keys' that are unique and are also the key to the 'form full of data'.

We can use 'nonces' to do that.

Every 'nonce' has a 'state' with it such as 'newRequest', 'hasData'.

I will write a full, but minimal, program that will satisfy the requirements. We may as well 'structure' it so it can be easily understood so i will use the 'mvc' structure.

I haven't written it in the 'mvc' form yet. It just needs restructuring. Will post it as an answer later. What follows is some of my earlier thought about the issues.

Every form you send out MUST have a unique 'nonce' in it where the state of it can be checked when the, maybe, same form comes back later! In effect there are many possible 'active' forms 'out there' each with a 'unique nonce' and the associated state.

That is why i store them in a database.

If you want to use $_SESSION then you must use an array of 'nonces' and the current state for each one.

The state is: set to: 'hasData' when the user enters data in the form and it is accepted.

Upvotes: 1

quid
quid

Reputation: 1034

You need to add some logic to setting the $_SESSION['rnd'] variable rather than just setting it to 0 on form submission. You may want to also set a cookie with a really long time out. I've added the cookie in this as well.

  session_start();
  if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    if ($_SESSION['rnd'] == $_POST['rnd']) {
      $form_data = $_POST; //process form
      $_SESSION['rnd'] = "POSTED";
      setcookie("rnd", "POSTED", time()+3600*24*14); //cookie expires in 2 weeks
      $msg = 'data posted';
    } else {
      $msg = 'cannot post twice';
    }
  } else {
    $msg = 'ready to post';
    $rnd = rand(100,999);
    if($_SESSION['rnd'] == "POSTED" || $_COOKIE['rnd'] == "POSTED"){
        $_SESSION['rnd'] = "POSTED"; //set value of session if you're here because the cookie was detected
        $rnd = -100;
    } else {
        $_SESSION['rnd'] = $rnd;
    }
  }

If you want to reset the form on back the back button you can do that with this pretty simple javascript.

<script>
    var posted = document.getElementById("rnd").value();
    if(posted < 0){
        document.getElementById("form").reset();
    }
</script>

You'll need to add the id attribute "form" to your form for it to work

  <form id="form" method="post">

Upvotes: 1

Related Questions