Skip to the content
Documentation

eZ Form token extension

No ratings yet

The form token extension, ezformtoken, protects Exponential against cross-site request forgery (CSRF): a page of another site that makes a signed-in user's browser send a form to your site, to act in that user's name. Every POST request of a signed-in user must carry a token that belongs to the user's session; a POST without it, or with a wrong one, is refused.

The extension ships in the kernel tree (extension/ezformtoken) and the setup wizard activates it on every new installation, whatever the site package. Nothing needs to be installed.

How it works

The extension listens to three kernel events ([Event] Listeners[] in the extension's settings/site.ini.append.php):

  • response/output: in every HTML page sent to a signed-in user, a hidden field ezxform_token is added to each form with method="post"; the token is also put in the page head as <meta name="csrf-token" id="ezxform_token_js" content="..."> (or, in a page without a <head>, in a hidden span with the id ezxform_token_js after <body>); and every @$ezxFormToken@ in the page is replaced by the token.
  • request/input: a POST of a signed-in user must carry the token in the field ezxform_token or in the HTTP header X-CSRF-Token. The token is compared in constant time.
  • session/regenerate: the token changes when the user signs in or out.

Anonymous visitors are not checked, and neither are GET requests. Content Security Policy reports the browser sends to csp/report are let through.

A page that carries a token is sent with Cache-Control: private, no-cache, must-revalidate, so no shared cache stores it and hands the token to someone else.

When a token is refused

A refused POST is answered with 403 Forbidden and a page that says the form has expired, nothing was saved, and the form should be reloaded and sent again (design:error/kernel/6.tpl). A request made with XMLHttpRequest or Accept: application/json gets the same message as JSON. One line goes to var/log/warning.log, without the token. Before 6.0.15 the visitor saw a 500 error.

Settings

# settings/override/site.ini.append.php
[HTMLForms]
# the secret the tokens are made with: set your own on every installation
Secret=ThisTokenIsNotSoSecretChangeIt
# repeated refusals within this many seconds are counted in one log line; 0 logs each one
RefusalLogCollapseSeconds=60

The status of the refusal is error.ini [ErrorSettings-kernel] HTTPError[6] (403). The debug messages of the extension are switched on with debug.ini [GeneralCondition] ezformtoken=enabled.

Your own JavaScript

HTML forms need no change: the token is added to them. JavaScript that sends a POST itself must add the token. The jQuery helpers of ezjscore, jQuery.ez() and jQuery.fn.ez(), already do. For any other code, read the token from the page:

var node = document.getElementById( 'ezxform_token_js' );
var token = node ? node.getAttribute( 'title' ) : '';

// as a form field
jQuery.post( url, { ezxform_token: token, name: 'value' } );

// or as a header, for a JSON body
fetch( url, { method: 'POST', headers: { 'X-CSRF-Token': token,
    'Content-Type': 'application/json' }, body: JSON.stringify( data ) } );

In a template, before the page is complete, @$ezxFormToken@ is replaced by the token:

jQuery.post( url, 'ezxform_token=@$ezxFormToken@' );

Only the HTML the kernel sends is filtered, not separate JavaScript or stylesheet files: there, read the token from the page as shown above.

Limits

  • A reverse proxy or site.ini [HTTPHeaderSettings] that caches pages of signed-in users serves stale tokens, which are then refused.
  • When a page has no <head>, the span is put after <body>; that filter does not work when an attribute of the body tag contains >.
  • Test custom code that posts with JavaScript before you enable the extension on an existing site that did not use it.

History

The extension was introduced with the 4.5 release, as a separate download that had to be activated. In the YUI era Y.io.ez() carried the token as well; YUI was removed in Exponential 6, and jQuery.ez() is the helper today.

More

Contributors

Comments

Add a comment

Comments are read by a moderator before they appear.