Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Merge branch 'form-signing' into dev

  • Loading branch information...
commit 94c74b0cf91347cdf4ff0444922bef7f0165d8f4 2 parents ce6b4fe + a16b0b2
@nateabele nateabele authored
View
113 security/validation/FormSignature.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Lithium: the most rad php framework
+ *
+ * @copyright Copyright 2012, Union of RAD (http://union-of-rad.org)
+ * @license http://opensource.org/licenses/bsd-license.php The BSD License
+ */
+
+namespace lithium\security\validation;
+
+use lithium\util\String;
+use lithium\util\Set;
+
+/**
+ * The `FormSignature` class cryptographically signs web forms, to prevent adding or removing
+ * fields, or modifying hidden (locked) fields.
+ *
+ * Using the `Security` helper, `FormSignature` calculates a hash of all fields in a form, so that
+ * when the form is submitted, the fields may be validated to ensure that none were added or
+ * removed, and that fields designated as _locked_ have not had their values altered.
+ *
+ * To enable form signing in a view, simply call `$this->security->sign()` before generating your
+ * form. In the controller, you may then validate the request by passing `$this->request` to the
+ * `check()` method.
+ *
+ * @see lithium\template\helper\Security::sign()
+ */
+class FormSignature {
+
+ /**
+ * Class dependencies.
+ *
+ * @var array
+ */
+ protected static $_classes = array(
+ 'password' => 'lithium\security\Password'
+ );
+
+ protected static $_salt = '$2a$10$NuNTOeXv4OHpPJtbdAmfRe';
+
+ /**
+ * Used to get or reconfigure dependencies with custom classes.
+ *
+ * @param array $config When assigning new configuration, should be an array containing a
+ * `'classes'` key.
+ * @return array If `$config` is empty, returns an array with a `'classes'` key containing class
+ * dependencies. Otherwise returns `null`.
+ */
+ public static function config(array $config = array()) {
+ if (!$config) {
+ return array('classes' => static::$_classes, 'salt' => static::$_salt);
+ }
+
+ foreach ($config as $key => $val) {
+ $key = "_{$key}";
+
+ if (!isset(static::${$key})) {
+ continue;
+ }
+ if (is_array(static::${$key})) {
+ static::${$key} = $val + static::${$key};
+ } else {
+ static::${$key} = $val;
+ }
+ }
+ }
+
+ public static function key(array $data) {
+ $classes = static::$_classes;
+ $data += array('fields' => array(), 'locked' => array(), 'excluded' => array());
+
+ $fields = array_keys(Set::flatten($data['fields']));
+ $excluded = array_keys($data['excluded']);
+ $locked = $data['locked'];
+
+ sort($fields, SORT_STRING);
+ sort($excluded, SORT_STRING);
+ ksort($locked, SORT_STRING);
+
+ foreach (array('fields', 'excluded', 'locked') as $list) {
+ ${$list} = urlencode(serialize(${$list}));
+ }
+ $hash = $classes['password']::hash($fields, static::$_salt);
+ $hash = $classes['password']::hash("{$locked}::{$excluded}::{$hash}", static::$_salt);
+
+ return "{$locked}::{$excluded}::{$hash}";
+ }
+
+ public static function check($data) {
+ if (is_object($data) && isset($data->data)) {
+ $data = $data->data;
+ }
+ if (!isset($data['security']['signature'])) {
+ return false;
+ }
+ $signature = $data['security']['signature'];
+ unset($data['security']);
+ $data = Set::flatten($data);
+ $fields = array_keys($data);
+
+ list($locked, $excluded, $hash) = explode('::', $signature, 3);
+ $locked = unserialize(urldecode($locked));
+ $excluded = unserialize(urldecode($excluded));
+ $fields = array_diff($fields, $excluded);
+
+ if (array_intersect_assoc($data, $locked) != $locked) {
+ return false;
+ }
+ return $signature === static::key(compact('fields', 'locked', 'excluded'));
+ }
+}
+
+?>
View
57 template/helper/Form.php
@@ -808,32 +808,43 @@ public function error($name, $key = null, array $options = array()) {
* @return array Defaults array contents.
*/
protected function _defaults($method, $name, $options) {
- $methodConfig = isset($this->_config[$method]) ? $this->_config[$method] : array();
- $options += $methodConfig + $this->_config['base'];
- $options = $this->_generators($method, $name, $options);
+ $config = $this->_config;
+ $params = compact('method', 'name', 'options');
+ $tpls = $this->_templateMap;
- $hasValue = (
- (!isset($options['value']) || $options['value'] === null) &&
- $name && $value = $this->binding($name)->data
- );
- $isZero = (isset($value) && ($value === 0 || $value === "0"));
- if ($hasValue || $isZero) {
- $options['value'] = $value;
- }
- if (isset($options['value']) && !$isZero) {
- $isZero = ($options['value'] === 0 || $options['value'] === "0");
- }
- if (isset($options['default']) && empty($options['value']) && !$isZero) {
- $options['value'] = $options['default'];
- }
- unset($options['default']);
+ return $this->_filter(__METHOD__, $params, function($self, $params) use ($config, $tpls) {
+ $method = $params['method'];
+ $name = $params['name'];
+ $options = $params['options'];
+
+ $methodConfig = isset($config[$method]) ? $config[$method] : array();
+ $options += $methodConfig + $config['base'];
+ $options = $this->invokeMethod('_generators', array($method, $name, $options));
- $generator = $this->_config['attributes']['name'];
- $name = $generator($method, $name, $options);
+ $hasValue = (
+ (!isset($options['value']) || $options['value'] === null) &&
+ $name && $value = $self->binding($name)->data
+ );
+ $isZero = (isset($value) && ($value === 0 || $value === "0"));
- $tplKey = isset($options['template']) ? $options['template'] : $method;
- $template = isset($this->_templateMap[$tplKey]) ? $this->_templateMap[$tplKey] : $tplKey;
- return array($name, $options, $template);
+ if ($hasValue || $isZero) {
+ $options['value'] = $value;
+ }
+ if (isset($options['value']) && !$isZero) {
+ $isZero = ($options['value'] === 0 || $options['value'] === "0");
+ }
+ if (isset($options['default']) && empty($options['value']) && !$isZero) {
+ $options['value'] = $options['default'];
+ }
+ unset($options['default']);
+
+ $generator = $config['attributes']['name'];
+ $name = $generator($method, $name, $options);
+
+ $tplKey = isset($options['template']) ? $options['template'] : $method;
+ $template = isset($tpls[$tplKey]) ? $tpls[$tplKey] : $tplKey;
+ return array($name, $options, $template);
+ });
}
/**
View
102 template/helper/Security.php
@@ -10,16 +10,20 @@
/**
* The `Security` helper is responsible for various tasks associated with verifying the authenticity
- * of requests, including embedding secure tokens to protect against CSRF attacks.
+ * of requests, including embedding secure tokens to protect against CSRF attacks, and signing forms
+ * to prevent adding or removing fields, or tampering with fields that are designated 'locked'.
*
* @see lithium\security\validation\RequestToken
*/
class Security extends \lithium\template\Helper {
protected $_classes = array(
- 'requestToken' => 'lithium\security\validation\RequestToken'
+ 'requestToken' => 'lithium\security\validation\RequestToken',
+ 'formSignature' => 'lithium\security\validation\FormSignature'
);
+ protected $_state = array();
+
/**
* Configures the helper with the default settings for interacting with security tokens.
*
@@ -51,6 +55,100 @@ public function requestToken(array $options = array()) {
unset($options['name']);
return $this->_context->form->hidden($name, compact('value') + $options);
}
+
+ /**
+ * Binds the `Security` helper to the `Form` helper to create a signature used to secure form
+ * fields against tampering.
+ *
+ * {{{
+ * // view:
+ * <?php $this->security->sign(); ?>
+ * <?=$this->form->create(...); ?>
+ * // Form fields...
+ * <?=$this->form->end(); ?>
+ * }}}
+ *
+ * {{{
+ * // controller:
+ * if ($this->request->is('post') && !FormSignature::check($this->request)) {
+ * // The key didn't match, meaning the request has been tampered with.
+ * }
+ * }}}
+ *
+ * Calling this method before a form is created adds two additional options to the `$options`
+ * parameter in all form inputs:
+ *
+ * - `'locked'` _boolean_: If `true`, _locks_ the value specified in the field when the field
+ * is generated, such that tampering with the value will invalidate the signature. Defaults
+ * to `true` for hidden fields, and `false` for all other form inputs.
+ *
+ * - `'exclude'` _boolean_: If `true`, this field and all subfields of the same name will be
+ * excluded from the signature calculation. This is useful in situations where fields may be
+ * added dynamically on the client side. Defaults to `false`.
+ *
+ * @see lithium\template\helper\Form
+ * @see lithium\security\validation\FormSignature
+ * @param object $form Optional. Allows specifying an instance of the `Form` helper manually.
+ * @return void
+ */
+ public function sign($form = null) {
+ $state =& $this->_state;
+ $classes = $this->_classes;
+ $form = $form ?: $this->_context->form;
+ $id = spl_object_hash($form);
+ $hasBound = isset($state[$id]);
+
+ if ($hasBound) {
+ return;
+ }
+
+ $form->applyFilter('create', function($self, $params, $chain) use ($form, &$state) {
+ $id = spl_object_hash($form);
+ $state[$id] = array('fields' => array(), 'locked' => array(), 'excluded' => array());
+ return $chain->next($self, $params, $chain);
+ });
+
+ $form->applyFilter('end', function($self, $params, $chain) use ($form, &$state, $classes) {
+ $id = spl_object_hash($form);
+
+ if (!$state[$id]) {
+ return $chain->next($self, $params, $chain);
+ }
+ $value = $classes['formSignature']::key($state[$id]);
+ echo $form->hidden('security.signature', compact('value'));
+
+ $state[$id] = array();
+ return $chain->next($self, $params, $chain);
+ });
+
+ $form->applyFilter('_defaults', function($self, $params, $chain) use ($form, &$state) {
+ $defaults = array('locked' => false, 'exclude' => false);
+ $options = $params['options'];
+
+ if ($params['method'] === 'hidden' && !isset($options['locked'])) {
+ $options['locked'] = true;
+ }
+ $options += $defaults;
+ $params['options'] = array_diff_key($options, $defaults);
+ $result = $chain->next($self, $params, $chain);
+
+ if (isset($options['exclude']) && $options['exclude']) {
+ return $result;
+ }
+ $value = isset($params['options']['value']) ? $params['options']['value'] : "";
+
+ $type = array(
+ $options['exclude'] => 'excluded',
+ !$options['exclude'] => 'fields',
+ $options['locked'] => 'locked'
+ );
+ if (!$name = preg_replace('/(\.\d+)+$/', '', $params['name'])) {
+ return $result;
+ }
+ $state[spl_object_hash($form)][$type[true]][$name] = $value;
+ return $result;
+ });
+ }
}
?>
View
67 tests/cases/security/validation/FormSignatureTest.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Lithium: the most rad php framework
+ *
+ * @copyright Copyright 2012, Union of RAD (http://union-of-rad.org)
+ * @license http://opensource.org/licenses/bsd-license.php The BSD License
+ */
+
+namespace lithium\tests\cases\security\validation;
+
+use lithium\action\Request;
+use lithium\security\validation\FormSignature;
+
+class FormSignatureTest extends \lithium\test\Unit {
+
+ /**
+ * Tests that `FormSignature` fails to generate a matching signature for data where locked
+ * values have been tampered with.
+ */
+ public function testSignatureFailingForInvalidLockedFieldValue() {
+ $components = array(
+ 'a%3A1%3A%7Bs%3A6%3A%22active%22%3Bs%3A4%3A%22true%22%3B%7D',
+ 'a%3A0%3A%7B%7D',
+ '$2a$10$NuNTOeXv4OHpPJtbdAmfReFiSmFw5hmc6sSy8qwns6/DWNSSOjR1y'
+ );
+ $signature = join('::', $components);
+
+ $request = new Request(array('data' => array(
+ 'email' => 'foo@baz',
+ 'pass' => 'whatever',
+ 'active' => 'true',
+ 'security' => compact('signature')
+ )));
+ $this->assertTrue(FormSignature::check($request));
+
+ $request = new Request(array('data' => array(
+ 'email' => 'foo@baz',
+ 'pass' => 'whatever',
+ 'active' => 'false',
+ 'security' => compact('signature')
+ )));
+ $this->assertFalse(FormSignature::check($request));
+ }
+
+ /**
+ * Tests that `FormSignature` correctly ignores other fields in the `'security'` array when
+ * generating signatures.
+ */
+ public function testIgnoreSecurityFields() {
+ $components = array(
+ 'a%3A1%3A%7Bs%3A6%3A%22active%22%3Bs%3A4%3A%22true%22%3B%7D',
+ 'a%3A0%3A%7B%7D',
+ '$2a$10$NuNTOeXv4OHpPJtbdAmfReFiSmFw5hmc6sSy8qwns6/DWNSSOjR1y'
+ );
+ $signature = join('::', $components);
+
+ $request = new Request(array('data' => array(
+ 'email' => 'foo@baz',
+ 'pass' => 'whatever',
+ 'active' => 'true',
+ 'security' => compact('signature') + array('foo' => 'bar')
+ )));
+ $this->assertTrue(FormSignature::check($request));
+ }
+}
+
+?>
View
7 tests/cases/template/helper/FormTest.php
@@ -287,6 +287,13 @@ public function testHiddenFieldWithId() {
)));
}
+ public function testHiddenFieldWithValue() {
+ $result = $this->form->hidden('my_field', array('value' => 'custom'));
+ $this->assertTags($result, array('input' => array(
+ 'type' => 'hidden', 'name' => 'my_field', 'id' => 'MyField', 'value' => 'custom'
+ )));
+ }
+
public function testLabelGeneration() {
$result = $this->form->label('next', 'Enter the next value >>');
$this->assertTags($result, array(
View
39 tests/cases/template/helper/SecurityTest.php
@@ -8,7 +8,10 @@
namespace lithium\tests\cases\template\helper;
+use lithium\action\Request;
+use lithium\template\helper\Form;
use lithium\template\helper\Security;
+use lithium\security\validation\FormSignature;
use lithium\tests\mocks\template\helper\MockFormRenderer;
class SecurityTest extends \lithium\test\Unit {
@@ -58,6 +61,42 @@ public function testConstruct() {
)));
$this->assertPattern('/value="WORKING"/', $this->subject->requestToken());
}
+
+ /**
+ * Tests that the `Security` helper correctly binds to the `Form` helper to collect field
+ * information and generate a signature.
+ */
+ public function testFormSignatureGeneration() {
+ $form = new Form(array('context' => $this->context));
+ $this->subject->sign($form);
+
+ ob_start();
+ $content = array(
+ $form->create(null, array('url' => 'http:///')),
+ $form->text('email', array('value' => 'foo@bar')),
+ $form->password('pass'),
+ $form->hidden('active', array('value' => 'true')),
+ $form->end()
+ );
+ $signature = ob_get_clean();
+ preg_match('/value="([^"]+)"/', $signature, $match);
+ list(, $signature) = $match;
+
+ $expected = array(
+ 'a%3A1%3A%7Bs%3A6%3A%22active%22%3Bs%3A4%3A%22true%22%3B%7D',
+ 'a%3A0%3A%7B%7D',
+ '$2a$10$NuNTOeXv4OHpPJtbdAmfReFiSmFw5hmc6sSy8qwns6/DWNSSOjR1y'
+ );
+ $this->assertEqual(join('::', $expected), $signature);
+
+ $request = new Request(array('data' => array(
+ 'email' => 'foo@baz',
+ 'pass' => 'whatever',
+ 'active' => 'true',
+ 'security' => compact('signature')
+ )));
+ $this->assertTrue(FormSignature::check($request));
+ }
}
?>
Please sign in to comment.
Something went wrong with that request. Please try again.