Confirmation page for custom hook_menu() actions with confirm_form()

We're all aware of the fact that Form API in Drupal automatically protects us against Cross-site request forgery attacks (a.k.a. CSRF) by using tokens which are added to each form. If a developer uses Form API in a proper way (and he must), there's no need to worry about anything else - custom module and forms are protected from CSRF, or at least from this attack vector.

Things become more dangerous when we decide to build a custom action which is triggered after a user clicks on a link. This is very common in situations where we don't have a form per se and can't even think of a use case for it.

Let's imagine we want to have a link somewhere on a node page or a list of nodes, which is, when clicked, changes the value of the field in this node. It is very tempting to implement it in this way:

  1. Create a link pointing to a hook_menu() path;
  2. Create a hook_menu() item with a page callback, take parameter(s) from the URL, load the node object;
  3. Implement the callback and throw the logic inside, probably adding a fancy confirmation page and validation.

This code generally works, and if proper access checks are used, it can't be exploited by an attacker directly to modify values in database, however it is still potentially dangerous:

  1. Specially crafted link can be sent to an authorized user and clicked;
  2. A developer might forget to implement access checks or implement them in the wrong way, so the code will be available for everyone to execute;
  3. An authorized user might click on the link accidentally, executing the code.

There are two possible solutions here and I'll touch the most common one here - adding a confirmation form with confirm_form(). Another option will be to generate the token manually and use it when building your links. More info on both approaches here.

Please be aware that this is not a real working code and should be used for a general guidance only.

Ok, let's start and implement our custom action in a Drupal way!

First, we need a menu link. I'll assume we can have multiple links so will prepare it in an array. Another concept used below is the drupal_get_destination() function, which prepares a 'destination' URL query parameter to be used in other places. We will need it later for creating the "Cancel" button.

$items = array(
  l(t('Change value'), 'action/change/' . $node->nid, array(
    'attributes' => array('class' => 'custom-css-class'),
    'query' => drupal_get_destination(),
    )),
);

Then, obviously, we need to register a path. Instead of a custom callback the drupal_get_form() is used. We're also loading a node object by automatically invoking node_load() (see %node argument). If you have a custom entity, it should be loaded in a separate function.

/**
 * Implements hook_menu().
 */
function modulename_menu() {
  $items = array();
  $items['action/change/%node'] = array(
    'page callback' => 'drupal_get_form',
    'page arguments' => array('action_confirm', 2),
    'access callback' => 'user_access',
    'access arguments' => array('access administration pages'),
    'type' => MENU_CALLBACK,
  );

  return $items;
}

Next, pass the node object to the form function and build the confirmation form. See the API for more details on confirm_form usage. And we have a destination query in the URL which allows to redirect a user to the previous page after clicking on "Cancel" button.

function action_confirm($form, &$form_state, $node) {

  // Store the node object in a form for using in submit handler later.
  $form['_node'] = array(
    '#type' => 'value',
    '#value' => $node,
  );

  return confirm_form($form,
    t('Are you sure you want to do this?'),
    isset($_GET['destination']) ? $_GET['destination'] : "node",
    t('This action cannot be undone.'),
    t('Execute'),
    t('Cancel'));
}

And finally, our submit function is invoked automatically (pay attention to the naming convention).

/**
 * Changes value of the field.
 */
function action_confirm_submit($form, &$form_state) {
  $values = $form_state['values'];

  // User clicked on the "Execute" button.
  if ($form_state['values']['confirm']) {
    $node = $values['_node'];

    // Put your logic here.

    drupal_set_message(t('Field changed'));
    drupal_goto('node');
  }

  return FALSE;
}

Any doubts/suggestions/issues - let me know in comments.

PS. In Drupal 8 this little trick will be no longer needed, see #1798296: Integrate CSRF link token directly into routing system
`