diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b9ccba5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Auto detect text files and perform LF normalization +* text eol=lf + +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpunit.xml.dist export-ignore +/README.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ec3ff7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/phpunit.xml +/vendor/ +/composer.lock +/coverage.xml \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cad0fdb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +**Don't** use issue tracker (nor send any pull request) if you find a **security** issue. +They are public, so please send an email to the address on my [Github profile](https://github.com/Giuseppe-Mazzapica) + +---- + +Before work on features or bug fix you might want to open an issue first. + +No need to do this for small things or evident bugs that need a fix. + +After the change or new feature has been discussed, the contributing flow is: + +1. Fork it +2. Create your feature or bug-fix branch +3. Make your changes +4. Commit your changes +5. Run the tests, adding new ones for your own code if necessary. +6. Repeat 4, 5 and 6 until all tests pass. +6. Push to the branch +7. Create a pull request from your branch to "dev" branch \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91c59e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Giuseppe Mazzapica + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5694241 --- /dev/null +++ b/README.md @@ -0,0 +1,286 @@ +Nonces +====== + +[![Travis CI](https://img.shields.io/travis/Brain-WP/Nonces.svg?style=flat-square)](https://travis-ci.org/Brain-WP/Nonces) +[![codecov.io](https://img.shields.io/codecov/c/github/Brain-WP/Nonces.svg?style=flat-square)](https://codecov.io/github/Brain-WP/Nonces) +[![MIT license](https://img.shields.io/packagist/l/brain/nonces.svg?style=flat-square)](http://opensource.org/licenses/MIT) + +------ + +** Nonces is an OOP package for WordPress to deal with nonces. ** + +------------- + +TOC + +- [Introduction](#Introduction) +- [How it works](#how-it-works) + - [Rethinking WordPress workflow](#rethinking-wordpress-workflow) + - [`NonceInterface` and `WpNonce`](#nonceinterface-and-wpnonce) + - [Nonce context](#nonce-context) + - [`RequestGlobalsContext`](#requestglobalscontext) + - [`Helpers`](#helpers) + - [`WpNonce` is blog-specific](#wpnonce-is-blog-specific) +- [Breaking SRP](#breaking-srp) +- [Installation](#installation) +- [Minimum Requirements](#minimum-requirements) +- [License](#license) +- [Contributing](#contributing) + +------------- + +# Introduction + +WordPress nonces functions does not really work well in an OOP context. + +They needs "keys" and "actions" to be passed around, ending up in code that hardcodes those strings +in classes code, or stores them in globally accessible place. + +Both solutions are not ideal. + +This package aims to provide a way to ease WordPress nonces usage in OOP code. + +The specific issues that are addressed are: + +- avoid dealing with somehow hardcoded nonce "keys" and "actions" +- have a way to customize nonce TTL on a per nonce basis +- have an approach more suitable for OOP, with enough flexibility to be extended with different + implementations of nonces + +# How it works + +## Rethinking WordPress workflow + +_WordPress_ nonces workflow is: + +1. For a "task" a nonce key and an nonce value are put in a request (as URL query variable or via + hidden form field). The "key" is just hardcoded, the value is generated with `wp_create_action()` + and it is an hash based on an "action" that is specific for the "task"; +2. The request handler extracts the nonce value from request data (so it needs to be aware of the + nonce "key") and validates it with `wp_verify_nonce()` that needs to be aware the "action". + +What we wanted to avoid is to have "keys" and "actions" that needs to be known where the nonce +is _created_ **and** where it is _validated_, causing the issue of tightly coupling between different +parts of the code as well as the more pragmatic issue of a place to store those values, or to have them +just hardcoded. + +_This package_ workflow is: + +1. For a "task" a nonce object is created, passing an action string to constructor. + There's no "key" and the "action" is not needed to be known anywhere else. +2. The request handler, needs to receive (as method argument or as a dependency injected to constructor) + an instance of the nonce task and use that object ot validate the request. + +So, using this package, the workflow would be something like (pseudo code): + +```php +class TaskForm { + + public function __construct(\Brain\Nonces\NonceInterface $nonce){ + $this->nonce = $nonce; + $this->url = admin_url('admin-post.php'); + } + + public function printFrom() { + $url = add_query_arg($this->nonce->action(), (string) $this->nonce, $this->url); + echo "
"; + // rest of form here... + } +} + +class TaskFormHandler { + + public function __construct(\Brain\Nonces\NonceInterface $nonce){ + $this->nonce = $nonce; + } + + public function saveForm() { + if (! $this->nonce->validate()) { + // handle error here... + } + + // continue processing here... + } +} +``` + +So the code responsible to build the form and the code responsible to process it, knows nothing + about "keys" or "actions", nor there's any string hardcoded anywhere. + +## `NonceInterface` and `WpNonce` + +The two classes on the example above receives an instance on `NonceInterface`. + +That interface has 3 methods: + +- `action()` +- `__toString()` +- `validate()` + +The package ships with just one implementation that is called `WpNonce` and wraps WordPress +functions to create and validate the nonce. + + +## Nonce context + +The `validate()` method of `NonceInterface` receives an optional parameter: an instance of +`NonceContextInterface`. + +The reason is that to validate the value it encapsulates, a nonce needs to know what to compare the +the value to. + +This package calls this value to be compared with nonce value "context". + +Nonce context is represented by a very simple interface that is no more than an extension of `ArrayAccess`. + +The reason is that even if WordPress implementation of nonces requires a string as "context" other +implementations may require different / more things. + +For example, I can imagine a nonce implementation that stores nonce values as user meta, and to verify +that nonce is valid would require not only the value itself, but also an user ID. + +Making the context an `ArrayAccess` instance, the package provides as much flexibility as possible +for custom implementations. + +## `RequestGlobalsContext` + +In the sample pseudo code above, `validate()` is called without passing any context. + +The reason is than when not provided (as it is optional) `WpNonce` creates and uses a default +implementation of `NonceContextInterface` that is `RequestGlobalsContext`. + +This implementation uses super globals (`$_GET` and `$_POST`) to "fill" the `ArrayAccess` storage +so that `validate()` will actually uses values from super globals as context when no other context +is provided. + +Being this the most common usage of nonces in WordPress, this simplify operations in large majority +of cases, still providing flexibility for even very custom implementations. + +Just for example, it would be very easy to build a `NonceContextInterface` implementation that takes +its value from HTTP headers (could be useful in REST context), still being able to use the +`WpNonce` class shipped with this package to validate it. + + +## Helpers + +Looking at the sample pseudo code above, when there was the need to "embed" the nonce in the HTML form, +the code uses `add_query_arg()` to add the nonce action and value as URL query variable. + +This is something that in core is done with `wp_nonce_url()`, however, that function takes as arguments +"action" and "key" as string and build the nonce value itself. + +Since we want encapsulate the creation of nonce value we can't really use that function. + +To provide the same level of "easiness", this package provides a function **`Brain\Nonces\nonceUrl()`** +that receives a nonce instance and an URL string and add the nonce action / value as URL query variable. + +The nonce instance is the first function argument and, unlike for WordPress core function, the URL +string is optional and if not provided defaults to current URL. + +However, in case of HTML forms, it is probably better to use a form field instead of a URL query variable. + +In WordPress that is done using `wp_nonce_field()`, this package provides **`Brain\Nonces\formField()`** +that receives a nonce instance and _returns_ the form field HTML markup. + +So, the above sample pseudo code could be updated like this: + +```php +class TaskForm { + + public function __construct(\Brain\Nonces\NonceInterface $nonce){ + $this->nonce = $nonce; + $this->url = admin_url('admin-post.php'); + } + + public function printFrom() { + $url = \Brain\Nonces\nonceUrl($this->nonce, $this->url); + echo ""; + // rest of form here... + } +} +``` + +or even better like this: + +```php +class TaskForm { + + public function __construct(\Brain\Nonces\NonceInterface $nonce){ + $this->nonce = $nonce; + $this->url = admin_url('admin-post.php'); + } + + public function printFrom() { + echo "url}>"; + echo \Brain\Nonces\formField($this->nonce); + // rest of form here... + } +} +``` + +Note that these two helpers accept an instance of `NonceInterface` and not of WordPress specific +`WpNonce` class, so they can be used with any custom implementation as well. + + +## `WpNonce` is blog-specific + +It is said above that the `WpNonce` class is a wrapper around WordPress functions. + +It is true, but besides of using `wp_create_nonce()` / `wp_verify_nonce()`, `WpNonce` automatically +adds to the action passed to its constructor the current blog id, when calling both those WordPress +functions. + +This ensures that when a nonce was generated in a blog context, will fail validating under another +blog context. + +This is a sanity check that avoid different issues in multisite context with plugins that switch blog +when, for example, saving post data; preventing to save meta data for posts of a blog into posts +of _another_ blog. + + +# Breaking SRP + +The package provides a couple of interfaces to abstract the nonce workflow, alongside implementations +that just wraps WordPress functions. + +The ideal OOP way to make this work, would be having separate interfaces for _nonces_ +(implemented as value object) and _nonce validators_. + +However, following that path every nonce validator would be very specific to a nonce implementation, +because to validate a nonce value a validator needs to be aware of how the nonce was built. + +So, probably, the package would need another class, a sort of factory, being able to create +instances of _nonces_ and _nonces validator_ being compatible each other. + +The thing is I thought that this would be really too much, at least in WordPress context. + +So I decided to break on purpose the "Single Responsibility Principle" and don't model nonce +instances as value objects, but as business objects that holds a value _and_ validates against a +context. + +This trade off gave me the chance to only deal with a single object, but still having a decent OOP +workflow and objects that, even breaking SRP, are no bigger than 50 lines of NCLOC. + + +# Installation + +Via Composer, require `brain\cortex` in version `~1.0.0`. + + +# Minimum Requirements + +- PHP 5.5+ +- Composer to install + + +# License + +MIT + + +# Contributing + +See `CONTRIBUTING.md`. + +**Don't** use issue tracker (nor send any pull request) if you find a **security** issue. +They are public, so please send an email to the address on my [Github profile](https://github.com/Giuseppe-Mazzapica). Thanks. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..77a643b --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "brain/nonces", + "description": "OOP package for WordPress to deal with nonces.", + "type": "package", + "keywords": [ + "wordpress", + "nonce", + "wordpress nonce", + "security" + ], + "license": "MIT", + "authors": [ + { + "name": "Giuseppe Mazzapica", + "email": "giuseppe.mazzapica@gmail.com", + "role": "Developer" + } + ], + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "brain/monkey": "~1.4", + "phpunit/phpunit": "~4.8" + }, + "autoload": { + "psr-4": { + "Brain\\Nonces\\": "src/" + }, + "files": [ + "inc/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Brain\\Nonces\\Tests\\": "tests/src/" + } + } +} diff --git a/inc/helpers.php b/inc/helpers.php new file mode 100644 index 0000000..2c28ddb --- /dev/null +++ b/inc/helpers.php @@ -0,0 +1,53 @@ +', + esc_attr($nonce->action()), + esc_attr((string)$nonce) + ); +} + +/** + * Adds nonces action and value to a given URL. + * + * If URL is not provided, current URL is used. + * + * @param NonceInterface $nonce + * @param string|null $url + * @return string + */ +function nonceUrl(NonceInterface $nonce, $url = null) +{ + if (!$url || !is_string($url)) { + $home_path = trim(parse_url(home_url(), PHP_URL_PATH), '/'); + $current_url_path = trim(add_query_arg([]), '/'); + if ($home_path && strpos($current_url_path, $home_path) === 0) { + $current_url_path = substr($current_url_path, strlen($home_path)); + } + $url = home_url(urldecode($current_url_path)); + } + + return esc_url_raw(add_query_arg($nonce->action(), (string)$nonce, $url)); +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d460aa4 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + ./src + + ./tests + ./vendor + + + + + + + + + ./tests/src/ + + + diff --git a/src/ArrayContext.php b/src/ArrayContext.php new file mode 100644 index 0000000..4e576c5 --- /dev/null +++ b/src/ArrayContext.php @@ -0,0 +1,78 @@ + + * @package Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +final class ArrayContext implements NonceContextInterface +{ + + private $storage = []; + + /** + * @param array $storage + */ + public function __construct(array $storage) + { + $this->storage = $storage; + } + + /** + * @inheritdoc + */ + public function offsetExists($offset) + { + return array_key_exists($offset, $this->storage); + } + + /** + * @inheritdoc + */ + public function offsetGet($offset) + { + return $this->offsetExists($offset) ? $this->storage[$offset] : null; + } + + /** + * Disabled. + * + * @param mixed $offset + * @param mixed $value + * + * @throws \BadMethodCallException + */ + public function offsetSet($offset, $value) + { + throw new \BadMethodCallException( + sprintf("Can't call %s, %s is read only.", __METHOD__, __CLASS__) + ); + } + + /** + * Disabled. + * + * @param mixed $offset + * + * @throws \BadMethodCallException + */ + public function offsetUnset($offset) + { + throw new \BadMethodCallException( + sprintf("Can't call %s, %s is read only.", __METHOD__, __CLASS__) + ); + } +} diff --git a/src/NonceContextInterface.php b/src/NonceContextInterface.php new file mode 100644 index 0000000..74f69dd --- /dev/null +++ b/src/NonceContextInterface.php @@ -0,0 +1,22 @@ + + * @package Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +interface NonceContextInterface extends \ArrayAccess +{ +} diff --git a/src/NonceInterface.php b/src/NonceInterface.php new file mode 100644 index 0000000..cad7b73 --- /dev/null +++ b/src/NonceInterface.php @@ -0,0 +1,48 @@ + + * @package Brain\Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +interface NonceInterface +{ + /** + * Returns the nonce action as string. + * + * @return string + */ + public function action(); + + /** + * Returns the nonce value as string. + * + * @return string + */ + public function __toString(); + + /** + * Validates the nonce against an optionally given context. + * + * What to do in case of missing context is left to implementations. + * + * Custom implementation of context interface can provide different values to be used for + * validation. + * + * @param NonceContextInterface $context + * @return bool + */ + public function validate(NonceContextInterface $context = null); +} diff --git a/src/RequestGlobalsContext.php b/src/RequestGlobalsContext.php new file mode 100644 index 0000000..d27e944 --- /dev/null +++ b/src/RequestGlobalsContext.php @@ -0,0 +1,90 @@ + + * @package Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +final class RequestGlobalsContext implements NonceContextInterface +{ + + /** + * @var ArrayContext context + */ + private $context; + + /** + * Constructor. + * + * We don't use `$_REQUEST` because, by default, in PHP it gives precedence to `$_GET` over + * `$_POST` in POST requests, and being dependant on `request_order` / `variables_order` ini + * configurations it is not consistent across systems. + */ + public function __construct() + { + $http_method = filter_input(INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_STRING); + + $request = strtoupper($http_method) === 'POST' ? array_merge($_GET, $_POST) : $_GET; + $this->context = new ArrayContext($request); + } + + /** + * Delegates to encapsulated context. + * + * @param mixed $offset + * @return bool + */ + public function offsetExists($offset) + { + return $this->context->offsetExists($offset); + } + + /** + * Delegates to encapsulated context. + * + * @param mixed $offset + * @return mixed + */ + public function offsetGet($offset) + { + return $this->context->offsetGet($offset); + } + + /** + * Delegates to encapsulated context. + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value) + { + $this->context->offsetSet($offset, $value); + } + + /** + * DDelegates to encapsulated context. + * + * @param mixed $offset + */ + public function offsetUnset($offset) + { + $this->context->offsetUnset($offset); + } +} diff --git a/src/WpNonce.php b/src/WpNonce.php new file mode 100644 index 0000000..d5a05fd --- /dev/null +++ b/src/WpNonce.php @@ -0,0 +1,141 @@ + + * @package Brain\Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +final class WpNonce implements NonceInterface +{ + + /** + * @var string + */ + private $action; + + /** + * @var int + */ + private $life; + + /** + * @var string + */ + private $nonce_value; + + /** + * Constructor. Save properties as instance variables. + * + * We allow to customize nonce Time To Live, defaulting to 30 minutes (1800 seconds) that + * is much less than the 24 hours of WordPress defaults. + * + * @param string $action + * @param int $life + */ + public function __construct($action = '', $life = 1800) + { + $this->action = is_string($action) ? $action : ''; + $this->life = is_numeric($life) ? (int)$life : 1800; + } + + /** + * @inheritdoc + */ + public function action() + { + return $this->action; + } + + /** + * Validates the nonce against given context. + * + * When not provided, context defaults to `RequestGlobalsContext`, so that value is searched + * in super globals. + * + * We need to filter the nonce life and remove the filter afterwards, because WP does not + * allow to filter nonce by action (yet? @link https://core.trac.wordpress.org/ticket/35188) + * + * @param NonceContextInterface $context + * @return bool + */ + public function validate(NonceContextInterface $context = null) + { + $context or $context = new RequestGlobalsContext(); + + $value = $context->offsetExists($this->action) ? $context[$this->action] : ''; + if (!$value || !is_string($value)) { + return false; + } + + $lifeFilter = $this->lifeFilter(); + + add_filter('nonce_life', $lifeFilter); + $valid = wp_verify_nonce($value, $this->hashedAction()); + remove_filter('nonce_life', $lifeFilter); + + return (bool)$valid; + } + + /** + * Returns the nonce string built with WordPress core function. + * + * We need to filter the nonce life and remove the filter afterwards, because WP does not + * allow to filter nonce by action (yet? @link https://core.trac.wordpress.org/ticket/35188) + * + * @return string Nonce value. + */ + public function __toString() + { + $lifeFilter = $this->lifeFilter(); + + add_filter('nonce_life', $lifeFilter); + $value = wp_create_nonce($this->hashedAction()); + remove_filter('nonce_life', $lifeFilter); + + $this->nonce_value = $value; + + return $this->nonce_value; + } + + /** + * Returns the callback that will be used to filter nonce life. + * + * @return \Closure + */ + private function lifeFilter() + { + return function () { + return $this->life; + }; + } + + /** + * Returns an hashed version of the action. + * + * Current blog id is appended to nonce action to make nonce blog specific. + * WordPress will hash the action, so we could avoid do the hashing here. + * However, unlike WordPress, we don't have a nonce "key" in URL or form fields, we use the + * action for that, so nonce action publicly clearly accessible. + * For this reason we do hash to make sure that trace back the nonce value from the action + * is as much hard as possible. + * This relies on a strong salt, which is required anyway for good WP security. + * + * @return string + */ + private function hashedAction() + { + return wp_hash($this->action . get_current_blog_id(), 'nonce'); + } +} diff --git a/tests/boot.php b/tests/boot.php new file mode 100644 index 0000000..1945961 --- /dev/null +++ b/tests/boot.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$vendor = dirname(__DIR__).'/vendor/'; + +if (! realpath($vendor)) { + die('Please install via Composer before running tests.'); +} + +require_once $vendor.'antecedent/patchwork/Patchwork.php'; +require_once $vendor.'autoload.php'; + +unset($vendor); diff --git a/tests/src/NoncesTest.php b/tests/src/NoncesTest.php new file mode 100644 index 0000000..c2cc00c --- /dev/null +++ b/tests/src/NoncesTest.php @@ -0,0 +1,165 @@ + + * @package Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +class NoncesTest extends TestCase +{ + + /** + * We mock here WP functions used to get current URL and append a query var to it. + */ + private function mockUrlFunctions() + { + Functions::expect('home_url')->withNoArgs()->andReturn('http://example.com/subdir'); + + Functions::expect('home_url') + ->with(\Mockery::type('string')) + ->andReturnUsing(function ($path) { + return 'http://example.com/subdir' . '/' . ltrim($path, '/'); + }); + + Functions::expect('add_query_arg')->with([])->andReturn('/subdir/foo'); + + Functions::expect('add_query_arg') + ->with(\Mockery::type('string'), \Mockery::type('string'), \Mockery::type('string')) + ->andReturnUsing(function ($key, $value, $url) { + $glue = strpos($url, '?') ? '&' : '?'; + + return "{$url}{$glue}{$key}={$value}"; + }); + + Functions::when('esc_url_raw')->alias(function ($url) { + return filter_var($url, FILTER_SANITIZE_URL); + }); + } + + /** + * We mock wp_create_nonce and wp_verify_nonce to be alias of md5 / hash_equals + */ + protected function setUp() + { + Functions::when('wp_create_nonce')->alias('md5'); + + Functions::expect('wp_hash')->andReturnUsing(function ($value, $scheme) { + return $scheme === 'nonce' ? md5($value) : ''; + }); + + Functions::expect('wp_verify_nonce')->andReturnUsing(function ($nonce, $action) { + return hash_equals(md5($action), $nonce); + }); + + parent::setUp(); + } + + /** + * Reset $_GET + */ + protected function tearDown() + { + $_GET = []; + parent::tearDown(); + } + + /** + * Test that nonces validate against a properly formed context passed to validation method. + */ + public function testGivenContext() + { + Functions::when('get_current_blog_id')->justReturn(1); + + $nonce_a = new WpNonce('action-a-'); + $nonce_b = new WpNonce('action-b-'); + $context = new ArrayContext([ + 'action-a-' => md5(md5('action-a-1')), + 'action-b-' => md5(md5('action-a-1')) + ]); + + self::assertSame('action-a-', $nonce_a->action()); + self::assertSame('action-b-', $nonce_b->action()); + + self::assertSame(md5(md5('action-a-1')), $nonce_a->__toString()); + self::assertSame(md5(md5('action-b-1')), $nonce_b->__toString()); + + self::assertTrue($nonce_a->validate($context)); + self::assertFalse($nonce_b->validate($context)); + } + + /** + * Test that nonces validate against context present in the $_GET (via URL query) when no + * context is passed to validation method. + */ + public function testGlobalsContextInUrl() + { + Functions::when('get_current_blog_id')->justReturn(1); + $this->mockUrlFunctions(); + + $nonce = new WpNonce('some-action'); + + $url = \Brain\Nonces\nonceUrl($nonce); + + // this is pretty much what PHP does + parse_str(parse_url($url, PHP_URL_QUERY), $_GET); + + self::assertTrue($nonce->validate()); + } + + /** + * Test that nonces validate against context present in the $_GET (via form field query) when no + * context is passed to validation method. + */ + public function testGlobalsContextInField() + { + Functions::when('get_current_blog_id')->justReturn(1); + Functions::when('esc_attr')->alias(function ($url) { + return filter_var($url, FILTER_SANITIZE_STRING); + }); + + $nonce = new WpNonce('some-action'); + + $field = \Brain\Nonces\formField($nonce); + + $name = preg_match('~name="([^"]+)"~', $field, $name_matches); + $value = preg_match('~value="([^"]+)"~', $field, $value_matches); + $_GET = $name && $value ? [$name_matches[1] => $value_matches[1]] : []; + + self::assertTrue($nonce->validate()); + } + + /** + * Test that nonces don't validate when blog id is different during nonce creation / validation. + */ + public function testFailingWhenDifferentBlogId() + { + Functions::expect('get_current_blog_id')->andReturn(1, 2, 3, 4, 5, 6); + + $this->mockUrlFunctions(); + + $nonce = new WpNonce('some-action'); + + $url = \Brain\Nonces\nonceUrl($nonce); + + // this is pretty much what PHP does + parse_str(parse_url($url, PHP_URL_QUERY), $_GET); + + self::assertFalse($nonce->validate()); + } + + +} \ No newline at end of file diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php new file mode 100644 index 0000000..949672e --- /dev/null +++ b/tests/src/TestCase.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Brain\Nonces\Tests; + +use Brain\Monkey; + +/** + * @author Giuseppe Mazzapica + * @package Brain\Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +class TestCase extends \PHPUnit_Framework_TestCase +{ + protected function setUp() + { + parent::setUp(); + Monkey::setUpWP(); + } + + protected function tearDown() + { + Monkey::tearDownWP(); + parent::tearDown(); + } + +} diff --git a/tests/src/WpNonceTest.php b/tests/src/WpNonceTest.php new file mode 100644 index 0000000..3a5d0ff --- /dev/null +++ b/tests/src/WpNonceTest.php @@ -0,0 +1,110 @@ + + * @package Nonces + * @license http://opensource.org/licenses/MIT MIT + */ +class WpNonceTest extends TestCase +{ + + /** + * Mock `et_current_blog_id()` to always return 1. + */ + protected function setUp() + { + Functions::when('get_current_blog_id')->justReturn(1); + parent::setUp(); + } + + /** + * Tests that nonce validation fails when given context passed does not contain nonce action + * as key. + */ + public function testValidateFailIfNoActionInContext() + { + $nonce = new WpNonce('foo'); + + $context = \Mockery::mock(NonceContextInterface::class); + $context->shouldReceive('offsetExists')->once()->with('foo')->andReturn(false); + + self::assertFalse($nonce->validate($context)); + } + + /** + * Tests that nonce validation pass when given context passed contain nonce action key and + * related value is what nonce expects. + * + */ + public function testValidate() + { + // `get_current_blog_id()` is mocked to return `1` + Functions::expect('wp_hash')->with('foo1', 'nonce')->andReturn(md5('foo1')); + + Functions::expect('wp_verify_nonce')->with('nonce-value', md5('foo1'))->andReturn(true); + + $nonce = new WpNonce('foo'); + + $context = \Mockery::mock(NonceContextInterface::class); + $context->shouldReceive('offsetExists')->once()->with('foo')->andReturn(true); + $context->shouldReceive('offsetGet')->once()->with('foo')->andReturn('nonce-value'); + + self::assertTrue($nonce->validate($context)); + } + + /** + * Tests that __toString() works as expected calling `wp_create_nonce()` and `wp_hash()` with + * expected arguments. + */ + public function testToString() + { + $hash = md5('foo1'); + + // `get_current_blog_id()` is mocked to return `1` + Functions::expect('wp_hash')->once()->with('foo1', 'nonce')->andReturn($hash); + Functions::expect('wp_create_nonce')->once()->with(md5('foo1'))->andReturn(md5($hash)); + + $nonce = new WpNonce('foo'); + + self::assertSame(md5($hash), (string)$nonce); + } + + /** + * Tests that when both calling `wp_create_nonce()` and `wp_verify_nonce()` the nonce life + * is filtered. + */ + public function testLifeIsFiltered() + { + Functions::when('wp_hash')->alias('md5'); + Functions::when('wp_create_nonce')->alias('md5'); + Functions::when('wp_verify_nonce')->justReturn(true); + + // twice because the same filter must be added on create and on verify + Filters::expectAdded('nonce_life')->twice()->with(\Mockery::type('Closure')); + + $context = \Mockery::mock(NonceContextInterface::class); + $context->shouldReceive('offsetExists')->once()->with('foo')->andReturn(true); + $context->shouldReceive('offsetGet')->once()->with('foo')->andReturn('nonce-value'); + + $nonce = new WpNonce('foo', 100); + + $nonce->validate($context); + $nonce->__toString(); + } + +} \ No newline at end of file