[CVE-2019-12246] Denial of Service on flush and development URL tools

This commit is contained in:
Serge Latyntcev 2019-02-27 14:50:49 +13:00 committed by Aaron Carlino
parent 7f69cc8f94
commit ca56e8d78e
79 changed files with 3940 additions and 1298 deletions

View File

@ -0,0 +1,35 @@
---
Name: confirmation_middleware-prototypes
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Middleware\ConfirmationMiddleware\AjaxBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\AjaxBypass
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\GetParameter:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\GetParameter
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswithCaseInsensitive:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswithCaseInsensitive
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\Url:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url
type: prototype

View File

@ -11,3 +11,5 @@ SilverStripe\Dev\DevelopmentAdmin:
controller: SilverStripe\Dev\TaskRunner
links:
tasks: 'See a list of build tasks to run'
confirm:
controller: SilverStripe\Dev\DevConfirmationController

View File

@ -32,6 +32,7 @@ SilverStripe\Core\Injector\Injector:
RequestHandler: '%$SilverStripe\Security\Security'
Middlewares:
- '%$SecurityRateLimitMiddleware'
---
Name: errorrequestprocessors
After:
@ -40,6 +41,8 @@ After:
SilverStripe\Core\Injector\Injector:
# Note: If Director config changes, take note it will affect this config too
SilverStripe\Core\Startup\ErrorDirector: '%$SilverStripe\Control\Director'
---
Name: canonicalurls
---
@ -48,3 +51,94 @@ SilverStripe\Core\Injector\Injector:
properties:
ForceSSL: false
ForceWWW: false
---
Name: url_specials-middleware
After:
- 'requestprocessors'
- 'coresecurity'
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
Middlewares:
URLSpecialsMiddleware: '%$SilverStripe\Control\Middleware\URLSpecialsMiddleware'
SilverStripe\Control\Middleware\URLSpecialsMiddleware:
class: SilverStripe\Control\Middleware\URLSpecialsMiddleware
properties:
ConfirmationStorageId: 'url-specials'
ConfirmationFormUrl: '/dev/confirm'
Bypasses:
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")'
EnforceAuthentication: true
AffectedPermissions:
- ADMIN
---
Name: dev_urls-confirmation-middleware
After:
- 'url_specials-middleware'
---
# This middleware enforces confirmation (CSRF protection) for all URLs
# that start with "dev/*", with the exception for "dev/build" which is handled
# by url_specials-middleware
# If you want to make exceptions for some URLs,
# see "dev_urls-confirmation-exceptions" config
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
Middlewares:
DevUrlsConfirmationMiddleware: '%$DevUrlsConfirmationMiddleware'
DevUrlsConfirmationMiddleware:
class: SilverStripe\Control\Middleware\PermissionAwareConfirmationMiddleware
constructor:
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev")'
properties:
ConfirmationStorageId: 'dev-urls'
ConfirmationFormUrl: '/dev/confirm'
Bypasses:
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
EnforceAuthentication: false
AffectedPermissions:
- ADMIN
---
Name: dev_urls-confirmation-exceptions
After:
- 'dev_urls-confirmation-middleware'
---
# This config is the place to add custom bypasses for modules providing UIs
# on top of DevelopmentAdmin (dev/*)
# If the module has its own CSRF protection, the easiest way would be to
# simply add UrlPathStartswith with the path to the mount point.
# Example:
# # This will prevent confirmation for all URLs starting with "dev/custom-module-endpoint/"
# # WARNING: this won't prevent confirmation for "dev/custom-module-endpoint-suffix/"
# - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/custom-module-endpoint")'
# If the module does not implement its own CSRF protection but exposes all
# dangerous effects through POST, then you could simply exclude GET and HEAD requests
# by using HttpMethodBypass("GET", "HEAD"). In that case GET/HEAD requests will not
# trigger confirmation redirects.
SilverStripe\Core\Injector\Injector:
DevUrlsConfirmationMiddleware:
properties:
Bypasses:
# dev/build is covered by URLSpecialsMiddleware
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/build")'
# The confirmation form is where people will be redirected for confirmation. We don't want to block it.
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")'
# Allows GET requests to the dev index page
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\Url("dev", ["GET", "HEAD"])'

View File

@ -1,7 +1,7 @@
# Environment management
As part of website development and hosting it is natural for our sites to be hosted on several different environments.
These can be our laptops for local development, a testing server for customers to test changes on, or a production
These can be our laptops for local development, a testing server for customers to test changes on, or a production
server.
For each of these environments we may require slightly different configurations for our servers. This could be our debug
@ -12,7 +12,7 @@ provides a set of APIs and helpers.
## Security considerations
Sensitive credentials should not be stored in a VCS or project code and should only be stored on the environment in
Sensitive credentials should not be stored in a VCS or project code and should only be stored on the environment in
question. When using live environments the use of `.env` files is discouraged and instead one should use "first class"
environment variables.
@ -29,7 +29,7 @@ set. An example `.env` file is included in the default installer named `.env.exa
## Managing environment variables with Apache
You can set "real" environment variables using Apache. Please
You can set "real" environment variables using Apache. Please
[see the Apache docs for more information](https://httpd.apache.org/docs/current/env.html)
## How to access the environment variables
@ -98,3 +98,4 @@ SilverStripe core environment variables are listed here, though you're free to d
| `SS_DATABASE_SSL_CERT` | Absolute path to SSL certificate file |
| `SS_DATABASE_SSL_CA` | Absolute path to SSL Certificate Authority bundle file |
| `SS_DATABASE_SSL_CIPHER` | Optional setting for custom SSL cipher |
| `SS_FLUSH_ON_DEPLOY` | Try to detect deployments through file system modifications and flush on the first request after every deploy. Does not run "dev/build", but only "flush". Possible values are `true` (check for a framework PHP file modification time), `false` (no checks, skip deploy detection) or a path to a specific file or folder to be checked. See [DeployFlushDiscoverer](api:SilverStripe\Core\Startup\DeployFlushDiscoverer) for more details.<br /><br />False by default. |

View File

@ -33,7 +33,7 @@ class CustomMiddleware implements HTTPMiddleware
return new HTTPResponse('You missed the special header', 400);
}
// You can modify the request before
// You can modify the request before
// For example, this might force JSON responses
$request->addHeader('Accept', 'application/json');
@ -118,4 +118,5 @@ SilverStripe\Control\Director:
## API Documentation
* [Built-in Middleware](./06_Builtin_Middlewares.md)
* [HTTPMiddleware](api:SilverStripe\Control\Middleware\HTTPMiddleware)

View File

@ -0,0 +1,21 @@
title: Built-in Middleware
summary: Middleware components that come with SilverStripe Framework
# Built-in Middleware
SilverStripe Framework has a number of Middleware components.
You may find them in the [SilverStripe\Control\Middleware](api:SilverStripe\Control\Middleware) namespace.
| Name | Description |
| ---- | ----------- |
| [AllowedHostsMiddleware](api:SilverStripe\Control\Middleware\AllowedHostsMiddleware) | Secures requests by only allowing a whitelist of Host values |
| [CanonicalURLMiddleware](api:SilverStripe\Control\Middleware\CanonicalURLMiddleware) | URL normalisation and redirection |
| [ChangeDetectionMiddleware](api:SilverStripe\Control\Middleware\ChangeDetectionMiddleware) | Change detection via Etag / IfModifiedSince headers, conditionally sending a 304 not modified if possible. |\
| [ConfirmationMiddleware](api:SilverStripe\Control\Middleware\ConfirmationMiddleware) | Checks whether user manual confirmation is required for HTTPRequest |
| [ExecMetricMiddleware](api:SilverStripe\Control\Middleware\ExecMetricMiddleware) | Display execution metrics in DEV mode |
| [FlushMiddleware](api:SilverStripe\Control\Middleware\FlushMiddleware) | Triggers a call to flush() on all [Flushable](api:SilverStripe\Core\Flushable) implementors |
| [HTTPCacheControlMiddleware](api:SilverStripe\Control\Middleware\HTTPCacheControlMiddleware) | Controls HTTP response cache headers |
| [RateLimitMiddleware](api:SilverStripe\Control\Middleware\RateLimitMiddleware) | Access throttling, controls HTTP Retry-After header |
| [SessionMiddleware](api:SilverStripe\Control\Middleware\SessionMiddleware) | PHP Session initialisation |
| [TrustedProxyMiddleware](api:SilverStripe\Control\Middleware\TrustedProxyMiddleware) | Rewrites headers that provide IP and host details from upstream proxies |
| [URLSpecialsMiddleware](api:SilverStripe\Control\Middleware\URLSpecialsMiddleware) | Controls some of the [URL special variables](../../debugging/url_variable_tools) |

View File

@ -26,8 +26,8 @@ We can use this to create a different base template with `LeftAndMain.ss`
Copy the template markup of the base implementation at `templates/SilverStripe/Admin/Includes/LeftAndMain_Menu.ss`
from the `silverstripe/admin` module
into `mysite/templates/Includes/LeftAndMain_Menu.ss`. It will automatically be picked up by
the CMS logic. Add a new section into the `<ul class="cms-menu-list">`
into `app/templates/SilverStripe/Admin/Includes/LeftAndMain_MenuList.ss`. It will automatically be picked up by
the CMS logic. Add a new section into the `<ul class="cms-menu__list">`
```ss
@ -123,10 +123,10 @@ Add the following code to a new file `mysite/code/BookmarkedLeftAndMainExtension
```php
use SilverStripe\Admin\LeftAndMainExtension;
class BookmarkedPagesLeftAndMainExtension extends LeftAndMainExtension
class BookmarkedPagesLeftAndMainExtension extends LeftAndMainExtension
{
public function BookmarkedPages()
public function BookmarkedPages()
{
return Page::get()->filter("IsBookmarked", 1);
}
@ -227,7 +227,7 @@ how-to.
## React-rendered UI
For sections of the admin that are rendered with React, Redux, and GraphQL, please refer
to [the introduction on those concepts](../07_ReactJS_Redux_and_GraphQL.md),
to [the introduction on those concepts](../../reactjs_redux_and_graphql/),
as well as their respective How-To's in this section.
### Implementing handlers
@ -240,18 +240,18 @@ applicable controller actions to it:
```php
use SilverStripe\Admin\LeftAndMainExtension;
class CustomActionsExtension extends LeftAndMainExtension
class CustomActionsExtension extends LeftAndMainExtension
{
private static $allowed_actions = [
'sampleAction'
];
public function sampleAction()
{
// Create the web
}
}
```

View File

@ -1,6 +1,6 @@
title: Flushable
summary: Allows a class to define it's own flush functionality.
# Flushable
## Introduction
@ -9,6 +9,14 @@ Allows a class to define it's own flush functionality, which is triggered when `
[FlushMiddleware](api:SilverStripe\Control\Middleware\FlushMiddleware) is run before a request is made, calling `flush()` statically on all
implementors of [Flushable](api:SilverStripe\Core\Flushable).
<div class="notice">
Flushable implementers might also be triggered automatically on deploy if you have `SS_FLUSH_ON_DEPLOY` [environment
variable](../configuration/environment_variables) defined. In that case even if you don't manually pass `flush=1` parameter, the first request after deploy
will still be calling `Flushable::flush` on those entities.
</div>
## Usage
To use this API, you need to make your class implement [Flushable](api:SilverStripe\Core\Flushable), and define a `flush()` static function,
@ -25,15 +33,15 @@ use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Flushable;
use Psr\SimpleCache\CacheInterface;
class MyClass extends DataObject implements Flushable
class MyClass extends DataObject implements Flushable
{
public static function flush()
public static function flush()
{
Injector::inst()->get(CacheInterface::class . '.mycache')->clear();
}
public function MyCachedContent()
public function MyCachedContent()
{
$cache = Injector::inst()->get(CacheInterface::class . '.mycache')
$something = $cache->get('mykey');
@ -57,10 +65,10 @@ flush so they are re-created on demand.
use SilverStripe\ORM\DataObject;
use SilverStripe\Core\Flushable;
class MyClass extends DataObject implements Flushable
class MyClass extends DataObject implements Flushable
{
public static function flush()
public static function flush()
{
foreach(glob(ASSETS_PATH . '/_tempfiles/*.jpg') as $file) {
unlink($file);

View File

@ -2,8 +2,8 @@ title: Command Line Interface
summary: Automate SilverStripe, run Cron Jobs or sync with other platforms through the Command Line Interface.
introduction: Automate SilverStripe, run Cron Jobs or sync with other platforms through the Command Line Interface.
SilverStripe can call [Controllers](../controllers) through a command line interface (CLI) just as easily as through a
web browser. This functionality can be used to automate tasks with cron jobs, run unit tests, or anything else that
SilverStripe can call [Controllers](../controllers) through a command line interface (CLI) just as easily as through a
web browser. This functionality can be used to automate tasks with cron jobs, run unit tests, or anything else that
needs to interface over the command line.
The main entry point for any command line execution is `cli-script.php` in the framework module.
@ -15,18 +15,18 @@ php vendor/silverstripe/framework/cli-script.php dev/build
```
<div class="notice">
Your command line php version is likely to use a different configuration as your webserver (run `php -i` to find out
Your command line php version is likely to use a different configuration as your webserver (run `php -i` to find out
more). This can be a good thing, your CLI can be configured to use higher memory limits than you would want your website
to have.
</div>
## Sake - SilverStripe Make
Sake is a simple wrapper around `cli-script.php`. It also tries to detect which `php` executable to use if more than one
Sake is a simple wrapper around `cli-script.php`. It also tries to detect which `php` executable to use if more than one
are available. It is accessible via `vendor/bin/sake`.
<div class="info" markdown='1'>
If you are using a Debian server: Check you have the php-cli package installed for sake to work. If you get an error
If you are using a Debian server: Check you have the php-cli package installed for sake to work. If you get an error
when running the command php -v, then you may not have php-cli installed so sake won't work.
</div>
@ -45,8 +45,8 @@ This currently only works on UNIX like systems, not on Windows.
### Configuration
Sometimes SilverStripe needs to know the URL of your site. For example, when sending an email or generating static
files. When you're visiting the site in a web browser this is easy to work out, but when executing scripts on the
Sometimes SilverStripe needs to know the URL of your site. For example, when sending an email or generating static
files. When you're visiting the site in a web browser this is easy to work out, but when executing scripts on the
command line, it has no way of knowing.
You can use the `SS_BASE_URL` environment variable to specify this.
@ -74,6 +74,11 @@ sake dev/
sake dev/build "flush=1"
```
<div class="alert" markdown="1">
You have to run "sake" with the same system user that runs your web server,
otherwise "flush" won't be able to clean the cache properly.
</div>
It can also be handy if you have a long running script..
```bash
@ -84,10 +89,10 @@ sake dev/tasks/MyReallyLongTask
`sake` can be used to make daemon processes for your application.
Make a task or controller class that runs a loop. To avoid memory leaks, you should make the PHP process exit when it
Make a task or controller class that runs a loop. To avoid memory leaks, you should make the PHP process exit when it
hits some reasonable memory limit. Sake will automatically restart your process whenever it exits.
Include some appropriate `sleep()`s so that your process doesn't hog the system. The best thing to do is to have a short
Include some appropriate `sleep()`s so that your process doesn't hog the system. The best thing to do is to have a short
sleep when the process is in the middle of doing things, and a long sleep when doesn't have anything to do.
This code provides a good template:
@ -96,7 +101,7 @@ This code provides a good template:
```php
use SilverStripe\Control\Controller;
class MyProcess extends Controller
class MyProcess extends Controller
{
private static $allowed_actions = [
@ -147,7 +152,7 @@ vendor/bin/sake myurl "myparam=1&myotherparam=2"
## Running Regular Tasks With Cron
On a UNIX machine, you can typically run a scheduled task with a [cron job](http://en.wikipedia.org/wiki/Cron). Run
`BuildTask` in SilverStripe as a cron job using `sake`.
`BuildTask` in SilverStripe as a cron job using `sake`.
The following will run `MyTask` every minute.

View File

@ -0,0 +1,131 @@
# 4.4.0 (Unreleased)
## Overview {#overview}
- Security patch for [SS-2018-022](https://www.silverstripe.org/download/security-releases/ss-2018-022)
- [Correct PHP types are now returned from database queries](/developer_guides/model/sql_select#data-types)
- [Server Requirements](/getting_started/server_requirements/#web-server-software-requirements) have been refined:
MySQL 5.5 end of life reached in December 2018, thus SilverStripe 4.4 requires MySQL 5.6+.
SilverStripe 4.3 and prior still support MySQL 5.5 for their own lifetime.
- The name of the directory where vendor module resources are exposed can now be configured by defining a `extra.resources-dir` key in your `composer.json` file. If the key is not set, it will automatically default to `resources`. New projects will be preset to `_resources`. This will avoid potential conflict with SiteTree URL Segments.
- dev/build is now non-destructive for all Enums, not just ClassNames. This means your data won't be lost if you're switching between versions, but watch out for code that breaks when it sees an unrecognised value!
### DevelopmentAdmin controllers
On Live environment all browser based HTTP requests to `/dev/*` urls get redirected to a confirmation form.
See more details below in the Upgrading section.
### DevelopmentAdmin cli-only mode
DevelopmentAdmin now has CLI-only mode (off by default).
The mode makes all `dev/*` controllers to be only accessible from CLI (e.g. sake).
To turn it on you may add the following configuration to your project configs:
```yml
SilverStripe\Dev\DevelopmentAdmin:
deny_non_cli: true
```
## Upgrading {#upgrading}
### Adopting to new `_resources` directory
1. Update your `.gitignore` file to ignore the new `_resources` directory. This file is typically located in the root of your project or in the `public` folder.
2. Add a new `extra.resources-dir` key to your composer file.
```js
{
// ...
"extra": {
// ...
"resources-dir": "_resources"
}
}
```
3. Expose your vendor assets by running `composer vendor-expose`.
4. Remove the old `resources` folder. This folder will be located in the `public` folder if you have adopted the public web root, or in the root of your project if you haven't.
You may also need to update your server configuration if you have applied special conditions to the `resources` path.
### DevelopmentAdmin controllers
The security fix for [SS-2018-022](https://www.silverstripe.org/download/security-releases/ss-2018-022) introduces a new
[Confirmation](api:SilverStripe\Security\Confirmation) component and
[ConfirmationMiddleware](api:SilverStripe\Control\Middleware\ConfirmationMiddleware) that prevents CSRF based attacks
on the urls placed under `dev/*` path.
If you use `dev/` endpoints, you may need to consider the following changes:
- `/dev/confirm` url now holds the confirmation form, where users will have to manually approve their actions
- on live environments all non-cli (browser based) HTTP requests to `dev/*` urls get redirected to the confirmation form
- ajax requests to `/dev/*` urls will be redirected as well (as such may stop working until configuration is added)
- `GET` and `POST` requests are handled gracefully, but other HTTP methods will be transformed to `GET` by the confirmation form
- you may add custom configuration for the confirmation middleware to prevent redirection for some requests or URLs
CLI based requests (e.g. sake) are not affected by the confirmation middleware and keep working as is.
If you are a 3rd party module developer and you extend DevelopmentAdmin adding new routes under the `dev/` path, you may
need to add custom rules for the confirmation middleware in your module configuration. Otherwise, people navigating
those through browsers will have to confirm every single action, which may impair user experience significantly.
You may find a configuration example in the framework `_config/requestprocessors.yml`
file, named `dev_urls-confirmation-exceptions`.
### ErrorControlChainMiddleware is deactivated
ErrorControlChainMiddleware has been deactivated and deprecated. It is going to be removed in SilverStripe 5.0.
That means uncaught exceptions and fatal errors will no longer trigger `flush` on live environments.
The main historic purpose of ErrorControlChainMiddleware was to detect the application state in which manifest cache is
incompatible with the source code, which might lead to fatal errors or unexpected exceptions. The only way this can
happen is when manifest cache has been generated and application source code files change afterwards.
The only reasonable cause for that on live environments would be application deployment.
Ideally, you should avoid reusing manifest cache between different application deploys, so that every newly deployed
version generates its own manifest cache. However, if that's not the case, you may want to consider using
`SS_FLUSH_ON_DEPLOY` setting, which automatically triggers `flush` on every deploy by comparing filesystem modification
time with cache generation timestamp. This effectively eliminates the possibility for the manifest cache to be incompatible
with the deployed app.
<div class="alert" markdown="1">
WARNING! If you do not deploy your application as a whole, but rather update its files in place with `rsync`, `ssh`
or `FTP`, you should consider triggering CLI based `flush` manually on every such deploy (e.g. with sake).
Otherwise, you may end up with your application cache to be incompatible with its source code, which would make things
broken. There is a tiny possibility that you wouldn't even be able to flush through browser in that case.
<br />
In that case you may consider using `SS_FLUSH_ON_DEPLOY`. Depending on your deployment tooling you may point it to a filesystem resource
that gets modified on every deploy update so that the framework will automatically perform `flush` for you.
<br />
The best practice is not to reuse the application manifest cache between deploys.
</div>
## Changes to internal APIs
- `PDOQuery::__construct()` now has a 2nd argument. If you have subclassed PDOQuery and overridden __construct()
you may see an E_STRICT error
- The name of the directory where vendor module resources are exposed can now be configured by adding a `extra.resources-dir` key to your composer file. The new default in `silverstripe/installer` has been changed to `_resources` rather than `resources`. This allows you to use `resources` as a URL segment or a route.
- `SilverStripe\Control\HTTPApplication` now uses `FlushDiscoverer` implementers to check for `flush`
- `SilverStripe\Control\Middleware\ConfirmationMiddleware` component implemented
- `SilverStripe\Control\Middleware\URLSpecialsMiddleware` component implemented
- `SilverStripe\Control\Director::isManifestFlushed` static function implemented
- `SilverStripe\Core\CoreKernel::isFlushed` function keeps boolean whether manifest cache has been flushed
- `SilverStripe\Core\Environment::isCli` method is now responsible for low level CLI detection (on before the kernel boot stage).
`Director::is_cli` is still to be used on the application level.
- `SilverStripe\Core\Startup\ErrorControlChainMiddleware::__construct` has a 2nd argument which activates its legacy behaviour
- `SilverStripe\Core\Startup\FlushDiscoverer` interface and a number of its implementations in the same namespace
- `SilverStripe\Dev\DevConfirmationController` implements the confirmation form for the `/dev/confirm` endpoint
- `SilverStripe\Dev\DevelopmentAdmin` now has `deny_non_cli` configuration parameter
- `SilverStripe\Security\Confirmation` component implemented
## Deprecations
- `SilverStripe\Control\Director::isManifestFlushed`
- `SilverStripe\Core\CoreKernel::getEnvironment`
- `SilverStripe\Core\CoreKernel::sessionEnvironment`
- `SilverStripe\Core\Startup\AbstractConfirmationToken`
- `SilverStripe\Core\Startup\ConfirmationTokenChain`
- `SilverStripe\Core\Startup\ErrorControlChain`
- `SilverStripe\Core\Startup\ErrorControlChainMiddleware`
- `SilverStripe\Core\Startup\ErrorDirector`
- `SilverStripe\Core\Startup\ParameterConfirmationToken`
- `SilverStripe\Core\Startup\URLConfirmationToken`

View File

@ -376,6 +376,33 @@ class Director implements TemplateGlobalProvider
return $response;
}
/**
* Returns indication whether the manifest cache has been flushed
* in the beginning of the current request.
*
* That could mean the current active request has `?flush` parameter.
* Another possibility is a race condition when the current request
* hits the server in between another request `?flush` authorisation
* and a redirect to the actual flush.
*
* @return bool
*
* @deprecated 5.0 Kernel::isFlushed to be used instead
*/
public static function isManifestFlushed()
{
$kernel = Injector::inst()->get(Kernel::class);
// Only CoreKernel implements this method at the moment
// Introducing it to the Kernel interface is a breaking change
if (method_exists($kernel, 'isFlushed')) {
return $kernel->isFlushed();
}
$classManifest = $kernel->getClassLoader()->getManifest();
return $classManifest->isFlushed();
}
/**
* Return the {@link SiteTree} object that is currently being viewed. If there is no SiteTree
* object to return, then this will return the current controller.
@ -1019,7 +1046,7 @@ class Director implements TemplateGlobalProvider
*/
public static function is_cli()
{
return in_array(php_sapi_name(), ['cli', 'phpdbg']);
return Environment::isCli();
}
/**
@ -1035,6 +1062,33 @@ class Director implements TemplateGlobalProvider
return $kernel->getEnvironment();
}
/**
* Returns the session environment override
*
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*
* @param HTTPRequest $request
*
* @return string|null null if not overridden, otherwise the actual value
*/
public static function get_session_environment_type(HTTPRequest $request = null)
{
$request = static::currentRequest($request);
if (!$request) {
return null;
}
$session = $request->getSession();
if (!empty($session->get('isDev'))) {
return Kernel::DEV;
} elseif (!empty($session->get('isTest'))) {
return Kernel::TEST;
}
}
/**
* This function will return true if the site is in a live environment. For information about
* environment types, see {@link Director::set_environment_type()}.

View File

@ -4,7 +4,14 @@ namespace SilverStripe\Control;
use SilverStripe\Control\Middleware\HTTPMiddlewareAware;
use SilverStripe\Core\Application;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Startup\FlushDiscoverer;
use SilverStripe\Core\Startup\CompositeFlushDiscoverer;
use SilverStripe\Core\Startup\CallbackFlushDiscoverer;
use SilverStripe\Core\Startup\RequestFlushDiscoverer;
use SilverStripe\Core\Startup\ScheduledFlushDiscoverer;
use SilverStripe\Core\Startup\DeployFlushDiscoverer;
/**
* Invokes the HTTP application within an ErrorControlChain
@ -18,11 +25,73 @@ class HTTPApplication implements Application
*/
protected $kernel;
/**
* A custom FlushDiscoverer to be kept here
*
* @var FlushDiscoverer
*/
private $flushDiscoverer = null;
/**
* Initialize the application with a kernel instance
*
* @param Kernel $kernel
*/
public function __construct(Kernel $kernel)
{
$this->kernel = $kernel;
}
/**
* Override the default flush discovery
*
* @param FlushDiscoverer $discoverer
*
* @return $this
*/
public function setFlushDiscoverer(FlushDiscoverer $discoverer)
{
$this->flushDiscoverer = $discoverer;
return $this;
}
/**
* Returns the current flush discoverer
*
* @param HTTPRequest $request a request to probe for flush parameters
*
* @return FlushDiscoverer
*/
public function getFlushDiscoverer(HTTPRequest $request)
{
if ($this->flushDiscoverer) {
return $this->flushDiscoverer;
}
return new CompositeFlushDiscoverer([
new ScheduledFlushDiscoverer($this->kernel),
new DeployFlushDiscoverer($this->kernel),
new RequestFlushDiscoverer($request, $this->getEnvironmentType())
]);
}
/**
* Return the current environment type (dev, test or live)
* Only checks Kernel and Server ENV as we
* don't have sessions initialized yet
*
* @return string
*/
protected function getEnvironmentType()
{
$kernel_env = $this->kernel->getEnvironment();
$server_env = Environment::getEnv('SS_ENVIRONMENT_TYPE');
$env = !is_null($kernel_env) ? $kernel_env : $server_env;
return $env;
}
/**
* Get the kernel for this application
*
@ -41,10 +110,10 @@ class HTTPApplication implements Application
*/
public function handle(HTTPRequest $request)
{
$flush = array_key_exists('flush', $request->getVars()) || ($request->getURL() === 'dev/build');
$flush = (bool) $this->getFlushDiscoverer($request)->shouldFlush();
// Ensure boot is invoked
return $this->execute($request, function (HTTPRequest $request) {
return $this->execute($request, static function (HTTPRequest $request) {
return Director::singleton()->handleRequest($request);
}, $flush);
}
@ -55,6 +124,7 @@ class HTTPApplication implements Application
* @param HTTPRequest $request
* @param callable $callback
* @param bool $flush
*
* @return HTTPResponse
*/
public function execute(HTTPRequest $request, callable $callback, $flush = false)

View File

@ -13,8 +13,10 @@ use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
/**
* Allows events to be registered and passed through middleware.
* Useful for event registered prior to the beginning of a middleware chain.
* Implements the following URL normalisation rules
* - redirect basic auth requests to HTTPS
* - force WWW, redirect to the subdomain "www."
* - force SSL, redirect to https
*/
class CanonicalURLMiddleware implements HTTPMiddleware
{

View File

@ -8,7 +8,7 @@ use SilverStripe\Core\Injector\Injectable;
/**
* Handles internal change detection via etag / ifmodifiedsince headers,
* conditonally sending a 304 not modified if possible.
* conditionally sending a 304 not modified if possible.
*/
class ChangeDetectionMiddleware implements HTTPMiddleware
{

View File

@ -0,0 +1,295 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\Session;
use SilverStripe\Security\Confirmation;
use SilverStripe\Security\Security;
/**
* Checks whether user manual confirmation is required for HTTPRequest
* depending on the rules given.
*
* How it works:
* - Gives the request to every single rule
* - If no confirmation items are found by the rules, then move on to the next middleware
* - initialize the Confirmation\Storage with all the confirmation items found
* - Check whether the storage has them confirmed already and if yes, move on to the next middleware
* - Otherwise redirect to the confirmation URL
*/
class ConfirmationMiddleware implements HTTPMiddleware
{
/**
* The confirmation storage identifier
*
* @var string
*/
protected $confirmationId = 'middleware';
/**
* Confirmation form URL
*
* @var string
*/
protected $confirmationFormUrl = '/dev/confirm';
/**
* The list of rules to check requests against
*
* @var ConfirmationMiddleware\Rule[]
*/
protected $rules;
/**
* The list of bypasses
*
* @var ConfirmationMiddleware\Bypass[]
*/
protected $bypasses = [];
/**
* Where user should be redirected when refusing
* the action on the confirmation form
*
* @var string
*/
private $declineUrl;
/**
* Init the middleware with the rules
*
* @param ConfirmationMiddleware\Rule[] $rules Rules to check requests against
*/
public function __construct(...$rules)
{
$this->rules = $rules;
$this->declineUrl = Director::baseURL();
}
/**
* The URL of the confirmation form ("Security/confirm/middleware" by default)
*
* @param HTTPRequest $request Active request
* @param string $confirmationStorageId ID of the confirmation storage to be used
*
* @return string URL of the confirmation form
*/
protected function getConfirmationUrl(HTTPRequest $request, $confirmationStorageId)
{
return Controller::join_links(
$this->confirmationFormUrl,
urlencode($confirmationStorageId)
);
}
/**
* Returns the URL where the user to be redirected
* when declining the action (on the confirmation form)
*
* @param HTTPRequest $request Active request
*
* @return string URL
*/
protected function generateDeclineUrlForRequest(HTTPRequest $request)
{
return $this->declineUrl;
}
/**
* Override the default decline url
*
* @param string $url
*
* @return $this
*/
public function setDeclineUrl($url)
{
$this->declineUrl = $url;
return $this;
}
/**
* Check whether the rules can be bypassed
* without user confirmation
*
* @param HTTPRequest $request
*
* @return bool
*/
public function canBypass(HTTPRequest $request)
{
foreach ($this->bypasses as $bypass) {
if ($bypass->checkRequestForBypass($request)) {
return true;
}
}
return false;
}
/**
* Extract the confirmation items from the request and return
*
* @param HTTPRequest $request
*
* @return Confirmation\Item[] list of confirmation items
*/
public function getConfirmationItems(HTTPRequest $request)
{
$confirmationItems = [];
foreach ($this->rules as $rule) {
if ($item = $rule->getRequestConfirmationItem($request)) {
$confirmationItems[] = $item;
}
}
return $confirmationItems;
}
/**
* Initialize the confirmation session storage
* with the confirmation items and return an HTTPResponse
* redirecting to the according confirmation form.
*
* @param HTTPRequest $request
* @param Confirmation\Storage $storage
* @param Confirmation\Item[] $confirmationItems
*
* @return HTTPResponse
*/
protected function buildConfirmationRedirect(HTTPRequest $request, Confirmation\Storage $storage, array $confirmationItems)
{
$storage->cleanup();
foreach ($confirmationItems as $item) {
$storage->putItem($item);
}
$storage->setSuccessRequest($request);
$storage->setFailureUrl($this->generateDeclineUrlForRequest($request));
$result = new HTTPResponse();
$result->redirect($this->getConfirmationUrl($request, $this->confirmationId));
return $result;
}
/**
* Process the confirmation items and either perform the confirmedEffect
* and pass the request to the next middleware, or return a redirect to
* the confirmation form
*
* @param HTTPRequest $request
* @param callable $delegate
* @param Confirmation\Item[] $items
*
* @return HTTPResponse
*/
protected function processItems(HTTPRequest $request, callable $delegate, $items)
{
$storage = Injector::inst()->createWithArgs(Confirmation\Storage::class, [$request->getSession(), $this->confirmationId, false]);
if (!count($storage->getItems())) {
return $this->buildConfirmationRedirect($request, $storage, $items);
}
$confirmed = false;
if ($storage->getHttpMethod() === 'POST') {
$postVars = $request->postVars();
$csrfToken = $storage->getCsrfToken();
$confirmed = $storage->confirm($postVars) && isset($postVars[$csrfToken]);
} else {
$confirmed = $storage->check($items);
}
if (!$confirmed) {
return $this->buildConfirmationRedirect($request, $storage, $items);
}
if ($response = $this->confirmedEffect($request)) {
return $response;
}
$storage->cleanup();
return $delegate($request);
}
/**
* The middleware own effects that should be performed on confirmation
*
* This method is getting called before the confirmation storage cleanup
* so that any responses returned here don't trigger a new confirmtation
* for the same request traits
*
* @param HTTPRequest $request
*
* @return null|HTTPResponse
*/
protected function confirmedEffect(HTTPRequest $request)
{
return null;
}
public function process(HTTPRequest $request, callable $delegate)
{
if ($this->canBypass($request)) {
if ($response = $this->confirmedEffect($request)) {
return $response;
} else {
return $delegate($request);
}
}
if (!$items = $this->getConfirmationItems($request)) {
return $delegate($request);
}
return $this->processItems($request, $delegate, $items);
}
/**
* Override the confirmation storage ID
*
* @param string $id
*
* @return $this
*/
public function setConfirmationStorageId($id)
{
$this->confirmationId = $id;
return $this;
}
/**
* Override the confirmation form url
*
* @param string $url
*
* @return $this
*/
public function setConfirmationFormUrl($url)
{
$this->confirmationFormUrl = $url;
return $this;
}
/**
* Set the list of bypasses for the confirmation
*
* @param ConfirmationMiddleware\Bypass[] $bypasses
*
* @return $this
*/
public function setBypasses($bypasses)
{
$this->bypasses = $bypasses;
return $this;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
/**
* Bypass for AJAX requests
*
* Relies on HTTPRequest::isAjax implementation
*/
class AjaxBypass implements Bypass
{
/**
* Returns true for AJAX requests
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequestForBypass(HTTPRequest $request)
{
return $request->isAjax();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A bypass for manual confirmation by user (depending on some runtime conditions)
*/
interface Bypass
{
/**
* Check the request for whether we can bypass
* the confirmation
*
* @param HTTPRequest $request
*
* @return bool True if we can bypass, False if the confirmation is required
*/
public function checkRequestForBypass(HTTPRequest $request);
}

View File

@ -0,0 +1,25 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Kernel;
/**
* Allows a bypass when the request has been run in CLI mode
*/
class CliBypass implements Bypass
{
/**
* Returns true if the current process is running in CLI mode
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequestForBypass(HTTPRequest $request)
{
return Director::is_cli();
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Kernel;
/**
* Allows a bypass for a list of environment types (e.g. DEV, TEST, LIVE)
*/
class EnvironmentBypass implements Bypass
{
/**
* The list of environments allowing a bypass for a confirmation
*
* @var string[]
*/
private $environments;
/**
* Initialize the bypass with the list of environment types
*
* @param string[] ...$environments
*/
public function __construct(...$environments)
{
$this->environments = $environments;
}
/**
* Returns the list of environments
*
* @return string[]
*
*/
public function getEnvironments()
{
return $this->environments;
}
/**
* Set the list of environments allowing a bypass
*
* @param string[] $environments List of environment types
*
* @return $this
*/
public function setEnvironments($environments)
{
$this->environments = $environments;
return $this;
}
/**
* Checks whether the current environment type in the list
* of allowed ones
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequestForBypass(HTTPRequest $request)
{
return in_array(Director::get_environment_type(), $this->environments, true);
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A rule to match a GET parameter within HTTPRequest
*/
class GetParameter implements Rule, Bypass
{
/**
* Parameter name
*
* @var string
*/
private $name;
/**
* Initialize the rule with a parameter name
*
* @param string $name
*/
public function __construct($name)
{
$this->setName($name);
}
/**
* Return the parameter name
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Set the parameter name
*
* @param string $name
*
* @return $this
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Generates the confirmation item
*
* @param string $token
*
* @return Confirmation\Item
*/
protected function buildConfirmationItem($token, $value)
{
return new Confirmation\Item(
$token,
_t(__CLASS__.'.CONFIRMATION_NAME', '"{key}" GET parameter', ['key' => $this->name]),
sprintf('%s = "%s"', $this->name, $value)
);
}
/**
* Generates the unique token depending on the path and the parameter
*
* @param string $path URL path
* @param string $param The parameter value
*
* @return string
*/
protected function generateToken($path, $value)
{
return sprintf('%s::%s?%s=%s', static::class, $path, $this->name, $value);
}
/**
* Check request contains the GET parameter
*
* @param HTTPRequest $request
*
* @return bool
*/
protected function checkRequestHasParameter(HTTPRequest $request)
{
return array_key_exists($this->name, $request->getVars());
}
public function checkRequestForBypass(HTTPRequest $request)
{
return $this->checkRequestHasParameter($request);
}
public function getRequestConfirmationItem(HTTPRequest $request)
{
if (!$this->checkRequestHasParameter($request)) {
return null;
}
$path = $request->getURL();
$value = $request->getVar($this->name);
$token = $this->generateToken($path, $value);
return $this->buildConfirmationItem($token, $value);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
/**
* Allows to bypass requests of a particular HTTP method
*/
class HttpMethodBypass implements Bypass
{
/**
* HTTP Methods to bypass
*
* @var string[]
*/
private $methods = [];
/**
* Initialize the bypass with HTTP methods
*
* @param string[] ...$method
*/
public function __construct(...$methods)
{
$this->addMethods(...$methods);
}
/**
* Returns the list of methods
*
* @return string[]
*/
public function getMethods()
{
return $this->methods;
}
/**
* Add new HTTP methods to the list
*
* @param string[] ...$methods
*
* return $this
*/
public function addMethods(...$methods)
{
// uppercase and exclude empties
$methods = array_reduce(
$methods,
static function &(&$result, $method) {
$method = strtoupper(trim($method));
if (strlen($method)) {
$result[] = $method;
}
return $result;
},
[]
);
foreach ($methods as $method) {
if (!in_array($method, $this->methods, true)) {
$this->methods[] = $method;
}
}
return $this;
}
/**
* Returns true if the current process is running in CLI mode
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequestForBypass(HTTPRequest $request)
{
return in_array($request->httpMethod(), $this->methods, true);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
/**
* Path aware trait for rules and bypasses
*/
trait PathAware
{
/**
* @var string
*/
private $path;
/**
* Returns the path
*
* @return string
*/
public function getPath()
{
return $this->path;
}
/**
* Update the path
*
* @param string $path
*
* @return $this
*/
public function setPath($path)
{
$this->path = $this->normalisePath($path);
return $this;
}
/**
* Returns the normalised version of the given path
*
* @param string $path Path to normalise
*
* @return string normalised version of the path
*/
protected function normalisePath($path)
{
if (substr($path, -1) !== '/') {
return $path . '/';
} else {
return $path;
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A rule for checking whether we need to protect a Request
*/
interface Rule
{
/**
* Check the request by the rule and return
* a confirmation item
*
* @param HTTPRequest $request
*
* @return null|Confirmation\Item Confirmation item if necessary to protect the request or null otherwise
*/
public function getRequestConfirmationItem(HTTPRequest $request);
}

View File

@ -0,0 +1,197 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A rule to match a particular URL
*/
class Url implements Rule, Bypass
{
use PathAware;
/**
* The HTTP methods
*
* @var HttpMethodBypass
*/
private $httpMethods;
/**
* The list of GET parameters URL should have to match
*
* @var array keys are parameter names, values are strings to match or null if any
*/
private $params = null;
/**
* Initialize the rule with the parameters
*
* @param string $path url path to check for
* @param string[]|string|null $httpMethods to match against
* @param string[]|null $params a list of GET parameters
*/
public function __construct($path, $httpMethods = null, $params = null)
{
$this->setPath($path);
$this->setParams($params);
$this->httpMethods = new HttpMethodBypass();
if (is_array($httpMethods)) {
$this->addHttpMethods(...$httpMethods);
} elseif (!is_null($httpMethods)) {
$this->addHttpMethods($httpMethods);
}
}
/**
* Add HTTP methods to check against
*
* @param string[] ...$methods
*
* @return $this
*/
public function addHttpMethods(...$methods)
{
$this->httpMethods->addMethods(...$methods);
return $this;
}
/**
* Returns HTTP methods to be checked
*
* @return string[]
*/
public function getHttpMethods()
{
return $this->httpMethods->getMethods();
}
/**
* Set the GET parameters
* null to skip parameter check
*
* If an array of parameters provided,
* then URL should contain ALL of them and
* ONLY them to match. If the values in the list
* contain strings, those will be checked
* against parameter values accordingly. Null
* as a value in the array matches any parameter values.
*
* @param string|null $httpMethods
*
* @return $this
*/
public function setParams($params = null)
{
$this->params = $params;
return $this;
}
public function checkRequestForBypass(HTTPRequest $request)
{
return $this->checkRequest($request);
}
public function getRequestConfirmationItem(HTTPRequest $request)
{
if (!$this->checkRequest($request)) {
return null;
}
$fullPath = $request->getURL(true);
$token = $this->generateToken($request->httpMethod(), $fullPath);
return $this->buildConfirmationItem($token, $fullPath);
}
/**
* Match the request against the rules
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequest(HTTPRequest $request)
{
$httpMethods = $this->getHttpMethods();
if (count($httpMethods) && !in_array($request->httpMethod(), $httpMethods, true)) {
return false;
}
if (!$this->checkPath($request->getURL())) {
return false;
}
if (!is_null($this->params)) {
$getVars = $request->getVars();
// compare the request parameters with the declared ones
foreach ($this->params as $key => $val) {
if (is_null($val)) {
$cmp = array_key_exists($key, $getVars);
} else {
$cmp = isset($getVars[$key]) && $getVars[$key] === strval($val);
}
if (!$cmp) {
return false;
}
}
// check only declared parameters exist in the request
foreach ($getVars as $key => $val) {
if (!array_key_exists($key, $this->params)) {
return false;
}
}
}
return true;
}
/**
* Checks the given path by the rules and
* returns true if it is matching
*
* @param string $path Path to be checked
*
* @return bool
*/
protected function checkPath($path)
{
return $this->getPath() === $this->normalisePath($path);
}
/**
* Generates the confirmation item
*
* @param string $token
* @param string $url
*
* @return Confirmation\Item
*/
protected function buildConfirmationItem($token, $url)
{
return new Confirmation\Item(
$token,
_t(__CLASS__.'.CONFIRMATION_NAME', 'URL is protected'),
_t(__CLASS__.'.CONFIRMATION_DESCRIPTION', 'The URL is: "{url}"', ['url' => $url])
);
}
/**
* Generates the unique token depending on the path
*
* @param string $path URL path
*
* @return string
*/
protected function generateToken($httpMethod, $path)
{
return sprintf('%s::%s|%s', static::class, $httpMethod, $path);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A rule to match beginning of URL
*/
class UrlPathStartswith implements Rule, Bypass
{
use PathAware;
/**
* Initialize the rule with the path
*
* @param string $path
*/
public function __construct($path)
{
$this->setPath($path);
}
/**
* Generates the confirmation item
*
* @param string $token
* @param string $url
*
* @return Confirmation\Item
*/
protected function buildConfirmationItem($token, $url)
{
return new Confirmation\Item(
$token,
_t(__CLASS__.'.CONFIRMATION_NAME', 'URL begins with "{path}"', ['path' => $this->getPath()]),
_t(__CLASS__.'.CONFIRMATION_DESCRIPTION', 'The complete URL is: "{url}"', ['url' => $url])
);
}
/**
* Generates the unique token depending on the path
*
* @param string $path URL path
*
* @return string
*/
protected function generateToken($path)
{
return sprintf('%s::%s', static::class, $path);
}
/**
* Checks the given path by the rules and
* returns whether it should be protected
*
* @param string $path Path to be checked
*
* @return bool
*/
protected function checkPath($path)
{
$targetPath = $this->getPath();
return strncmp($this->normalisePath($path), $targetPath, strlen($targetPath)) === 0;
}
public function checkRequestForBypass(HTTPRequest $request)
{
return $this->checkPath($request->getURL());
}
public function getRequestConfirmationItem(HTTPRequest $request)
{
if (!$this->checkPath($request->getURL())) {
return null;
}
$token = $this->generateToken($this->getPath());
return $this->buildConfirmationItem($token, $request->getURL(true));
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Security\Confirmation;
/**
* A case insensitive rule to match beginning of URL
*/
class UrlPathStartswithCaseInsensitive extends UrlPathStartswith
{
protected function checkPath($path)
{
$pattern = $this->getPath();
$mb_path = mb_strcut($this->normalisePath($path), 0, strlen($pattern));
return mb_stripos($mb_path, $pattern) === 0;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Assets\File;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Dev\Debug;
/**
* Display execution metrics for the current request if in dev mode and `execmetric` is provided as a request variable.
*/
class ExecMetricMiddleware implements HTTPMiddleware
{
public function process(HTTPRequest $request, callable $delegate)
{
if (!$this->showMetric($request)) {
return $delegate($request);
}
$start = microtime(true);
try {
return $delegate($request);
} finally {
$end = microtime(true);
Debug::message(
sprintf(
"Execution time: %s, Peak memory usage: %s\n",
$this->formatExecutionTime($start, $end),
$this->formatPeakMemoryUsage()
),
false
);
}
}
/**
* Check if execution metric should be shown.
* @param HTTPRequest $request
* @return bool
*/
private function showMetric(HTTPRequest $request)
{
return Director::isDev() && array_key_exists('execmetric', $request->getVars());
}
/**
* Convert the provided start and end time to a interval in secs.
* @param float $start
* @param float $end
* @return string
*/
private function formatExecutionTime($start, $end)
{
$diff = round($end - $start, 4);
return $diff . ' seconds';
}
/**
* Get the peak memory usage formatted has a string and a meaningful unit.
* @return string
*/
private function formatPeakMemoryUsage()
{
$bytes = memory_get_peak_usage(true);
return File::format_size($bytes);
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Flushable;
@ -11,12 +12,9 @@ use SilverStripe\Core\Flushable;
*/
class FlushMiddleware implements HTTPMiddleware
{
/**
* @inheritdoc
*/
public function process(HTTPRequest $request, callable $delegate)
{
if (array_key_exists('flush', $request->getVars())) {
if (Director::isManifestFlushed()) {
// Disable cache when flushing
HTTPCacheControlMiddleware::singleton()->disableCache(true);

View File

@ -0,0 +1,159 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
/**
* Extends the ConfirmationMiddleware with checks for user permissions
*
* Respects users who don't have enough access and does not
* ask them for confirmation
*
* By default it enforces authentication by redirecting users to a login page.
*
* How it works:
* - if user can bypass the middleware, then pass request further
* - if there are no confirmation items, then pass request further
* - if user is not authenticated and enforceAuthentication is false, then pass request further
* - if user does not have at least one of the affected permissions, then pass request further
* - otherwise, pass handling to the parent (ConfirmationMiddleware)
*/
class PermissionAwareConfirmationMiddleware extends ConfirmationMiddleware
{
/**
* List of permissions affected by the middleware
*
* @see setAffectedPermissions method for more details
*
* @var string[]
*/
private $affectedPermissions = [];
/**
* Wthether the middleware should redirect to a login form
* if the user is not authenticated
*
* @var bool
*/
private $enforceAuthentication = true;
/**
* Returns the list of permissions that are affected
*
* @return string[]
*/
public function getAffectedPermissions()
{
return $this->affectedPermissions;
}
/**
* Set the list of affected permissions
*
* If the user doesn't have at least one of these, we assume they
* don't have access to the protected action, so we don't ask
* for a confirmation
*
* @param string[] $permissions list of affected permissions
*
* @return $this
*/
public function setAffectedPermissions($permissions)
{
$this->affectedPermissions = $permissions;
return $this;
}
/**
* Returns flag whether we want to enforce authentication or not
*
* @return bool
*/
public function getEnforceAuthentication()
{
return $this->enforceAuthentication;
}
/**
* Set whether we want to enforce authentication
*
* We either enforce authentication (redirect to a login form)
* or silently assume the user does not have permissions and
* so we don't have to ask for a confirmation
*
* @param bool $enforce
*
* @return $this
*/
public function setEnforceAuthentication($enforce)
{
$this->enforceAuthentication = $enforce;
return $this;
}
/**
* Check whether the user has permissions to perform the target operation
* Otherwise we may want to skip the confirmation dialog.
*
* WARNING! The user has to be authenticated beforehand
*
* @param HTTPRequest $request
*
* @return bool
*/
public function hasAccess(HTTPRequest $request)
{
foreach ($this->getAffectedPermissions() as $permission) {
if (Permission::check($permission)) {
return true;
}
}
return false;
}
/**
* Returns HTTPResponse with a redirect to a login page
*
* @param HTTPRequest $request
*
* @return HTTPResponse redirect to a login page
*/
protected function getAuthenticationRedirect(HTTPRequest $request)
{
$backURL = $request->getURL(true);
$loginPage = sprintf(
'%s?BackURL=%s',
Director::absoluteURL(Security::config()->get('login_url')),
urlencode($backURL)
);
$result = new HTTPResponse();
$result->redirect($loginPage);
return $result;
}
protected function processItems(HTTPRequest $request, callable $delegate, $items)
{
if (!Security::getCurrentUser()) {
if ($this->getEnforceAuthentication()) {
return $this->getAuthenticationRedirect($request);
} else {
// assume the user does not have permissions anyway
return $delegate($request);
}
}
if (!$this->hasAccess($request)) {
return $delegate($request);
}
return parent::processItems($request, $delegate, $items);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\Middleware\URLSpecialsMiddleware\FlushScheduler;
use SilverStripe\Control\Middleware\URLSpecialsMiddleware\SessionEnvTypeSwitcher;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\RandomGenerator;
/**
* Check the request for the URL special variables.
* Performs authorisation, confirmation and actions for some of those.
*
* WARNING: Bypasses only disable authorisation and confirmation, but not actions nor redirects
*
* The rules are:
* - flush GET parameter
* - isDev GET parameter
* - isTest GET parameter
* - dev/build URL
*
* @see https://docs.silverstripe.org/en/4/developer_guides/debugging/url_variable_tools/ special variables docs
*
* {@inheritdoc}
*/
class URLSpecialsMiddleware extends PermissionAwareConfirmationMiddleware
{
use FlushScheduler;
use SessionEnvTypeSwitcher;
/**
* Initializes the middleware with the required rules
*/
public function __construct()
{
parent::__construct(
new ConfirmationMiddleware\GetParameter("flush"),
new ConfirmationMiddleware\GetParameter("isDev"),
new ConfirmationMiddleware\GetParameter("isTest"),
new ConfirmationMiddleware\UrlPathStartswith("dev/build")
);
}
/**
* Looks up for the special flags passed in the request
* and schedules the changes accordingly for the next request.
* Returns a redirect to the same page (with a random token) if
* there are changes introduced by the flags.
* Returns null if there is no impact introduced by the flags.
*
* @param HTTPRequest $request
*
* @return null|HTTPResponse redirect to the same url
*/
public function buildImpactRedirect(HTTPRequest $request)
{
$flush = $this->scheduleFlush($request);
$env_type = $this->setSessionEnvType($request);
if ($flush || $env_type) {
// the token only purpose is to invalidate browser/proxy cache
$request['urlspecialstoken'] = bin2hex(random_bytes(4));
$result = new HTTPResponse();
$result->redirect('/' . $request->getURL(true));
return $result;
}
}
protected function confirmedEffect(HTTPRequest $request)
{
if ($response = $this->buildImpactRedirect($request)) {
HTTPCacheControlMiddleware::singleton()->disableCache(true);
return $response;
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace SilverStripe\Control\Middleware\URLSpecialsMiddleware;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Startup\ScheduledFlushDiscoverer;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
/**
* Schedule flush operation for a following request
*
* The scheduler does not trigger a flush but rather puts a marker
* into the manifest cache so that one of the next Requests can
* find it and perform the actual manifest flush.
*/
trait FlushScheduler
{
/**
* Schedules the manifest flush operation for a following request
*
* WARNING! Does not perform flush, but schedules it for another request
*
* @param HTTPRequest $request
*
* @return bool true if flush has been scheduled, false otherwise
*/
public function scheduleFlush(HTTPRequest $request)
{
$flush = array_key_exists('flush', $request->getVars()) || ($request->getURL() === 'dev/build');
if (!$flush || Director::isManifestFlushed()) {
return false;
}
$kernel = Injector::inst()->get(Kernel::class);
ScheduledFlushDiscoverer::scheduleFlush($kernel);
return true;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace SilverStripe\Control\Middleware\URLSpecialsMiddleware;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Startup\ScheduledFlushDiscoverer;
use SilverStripe\Control\HTTPRequest;
/**
* Implements switching user session into Test and Dev environment types
*/
trait SessionEnvTypeSwitcher
{
/**
* Checks whether the request has GET flags to control
* environment type and amends the user session accordingly
*
* @param HTTPRequest $request
*
* @return bool true if changed the user session state, false otherwise
*/
public function setSessionEnvType(HTTPRequest $request)
{
$session = $request->getSession();
if (array_key_exists('isTest', $request->getVars())) {
if (!is_null($isTest = $request->getVar('isTest'))) {
if ($isTest === $session->get('isTest')) {
return false;
}
}
$session->clear('isDev');
$session->set('isTest', $isTest);
return true;
} elseif (array_key_exists('isDev', $request->getVars())) {
if (!is_null($isDev = $request->getVar('isDev'))) {
if ($isDev === $session->get('isDev')) {
return false;
}
}
$session->clear('isTest');
$session->set('isDev', $isDev);
return true;
}
return false;
}
}

View File

@ -77,6 +77,21 @@ class CoreKernel implements Kernel
protected $basePath = null;
/**
* Indicates whether the Kernel has been booted already
*
* @var bool
*/
private $booted = false;
/**
* Indicates whether the Kernel has been flushed on boot
* Unitialized before boot
*
* @var bool
*/
private $flush;
/**
* Create a new kernel for this application
*
@ -126,6 +141,13 @@ class CoreKernel implements Kernel
$this->setThemeResourceLoader($themeResourceLoader);
}
/**
* Get the environment type
*
* @return string
*
* @deprecated 5.0 use Director::get_environment_type() instead. Since 5.0 it should return only if kernel overrides. No checking SESSION or Environment.
*/
public function getEnvironment()
{
// Check set
@ -151,41 +173,23 @@ class CoreKernel implements Kernel
* Check or update any temporary environment specified in the session.
*
* @return null|string
*
* @deprecated 5.0 Use Director::get_session_environment_type() instead
*/
protected function sessionEnvironment()
{
// Check isDev in querystring
if (isset($_GET['isDev'])) {
if (isset($_SESSION)) {
unset($_SESSION['isTest']); // In case we are changing from test mode
$_SESSION['isDev'] = $_GET['isDev'];
}
return self::DEV;
if (!$this->booted) {
// session is not initialyzed yet, neither is manifest
return null;
}
// Check isTest in querystring
if (isset($_GET['isTest'])) {
if (isset($_SESSION)) {
unset($_SESSION['isDev']); // In case we are changing from dev mode
$_SESSION['isTest'] = $_GET['isTest'];
}
return self::TEST;
}
// Check session
if (!empty($_SESSION['isDev'])) {
return self::DEV;
}
if (!empty($_SESSION['isTest'])) {
return self::TEST;
}
// no session environment
return null;
return Director::get_session_environment_type();
}
public function boot($flush = false)
{
$this->flush = $flush;
$this->bootPHP();
$this->bootManifests($flush);
$this->bootErrorHandling();
@ -193,6 +197,8 @@ class CoreKernel implements Kernel
$this->bootConfigs();
$this->bootDatabaseGlobals();
$this->validateDatabase();
$this->booted = true;
}
/**
@ -647,4 +653,14 @@ class CoreKernel implements Kernel
$this->themeResourceLoader = $themeResourceLoader;
return $this;
}
/**
* Returns whether the Kernel has been flushed on boot
*
* @return bool|null null if the kernel hasn't been booted yet
*/
public function isFlushed()
{
return $this->flush;
}
}

View File

@ -222,4 +222,14 @@ class Environment
{
static::$env[$name] = $value;
}
/**
* Returns true if this script is being run from the command line rather than the web server
*
* @return bool
*/
public static function isCli()
{
return in_array(strtolower(php_sapi_name()), ['cli', 'phpdbg']);
}
}

View File

@ -166,6 +166,14 @@ class ClassManifest
*/
private $visitor;
/**
* Indicates whether the cache has been
* regenerated in the current process
*
* @var bool
*/
private $cacheRegenerated = false;
/**
* Constructs and initialises a new class manifest, either loading the data
* from the cache or re-scanning for classes.
@ -180,6 +188,72 @@ class ClassManifest
$this->cacheKey = 'manifest';
}
private function buildCache($includeTests = false)
{
if ($this->cache) {
return $this->cache;
} elseif (!$this->cacheFactory) {
return null;
} else {
return $this->cacheFactory->create(
CacheInterface::class . '.classmanifest',
['namespace' => 'classmanifest' . ($includeTests ? '_tests' : '')]
);
}
}
/**
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*
* @return int
*/
public function getManifestTimestamp($includeTests = false)
{
$cache = $this->buildCache($includeTests);
if (!$cache) {
return null;
}
return $cache->get('generated_at');
}
/**
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*/
public function scheduleFlush($includeTests = false)
{
$cache = $this->buildCache($includeTests);
if (!$cache) {
return null;
}
$cache->set('regenerate', true);
}
/**
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*/
public function isFlushScheduled($includeTests = false)
{
$cache = $this->buildCache($includeTests);
if (!$cache) {
return null;
}
return $cache->get('regenerate');
}
/**
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*/
public function isFlushed()
{
return $this->cacheRegenerated;
}
/**
* Initialise the class manifest
*
@ -188,13 +262,7 @@ class ClassManifest
*/
public function init($includeTests = false, $forceRegen = false)
{
// build cache from factory
if ($this->cacheFactory) {
$this->cache = $this->cacheFactory->create(
CacheInterface::class . '.classmanifest',
['namespace' => 'classmanifest' . ($includeTests ? '_tests' : '')]
);
}
$this->cache = $this->buildCache($includeTests);
// Check if cache is safe to use
if (!$forceRegen
@ -457,7 +525,11 @@ class ClassManifest
if ($this->cache) {
$data = $this->getState();
$this->cache->set($this->cacheKey, $data);
$this->cache->set('generated_at', time());
$this->cache->delete('regenerate');
}
$this->cacheRegenerated = true;
}
/**

View File

@ -14,6 +14,8 @@ use SilverStripe\Security\RandomGenerator;
* string parameters
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
abstract class AbstractConfirmationToken
{

View File

@ -0,0 +1,31 @@
<?php
namespace SilverStripe\Core\Startup;
/**
* Handle a callable object as a discoverer
*/
class CallbackFlushDiscoverer implements FlushDiscoverer
{
/**
* Callback incapsulating the discovery logic
*
* @var Callable
*/
protected $callback;
/**
* Construct the discoverer from a callback
*
* @param Callable $callback returning FlushDiscoverer response or a timestamp
*/
public function __construct(callable $callback)
{
$this->callback = $callback;
}
public function shouldFlush()
{
return call_user_func($this->callback);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace SilverStripe\Core\Startup;
/**
* Implements the composite over flush discoverers
*
* @see https://en.wikipedia.org/wiki/Composite_pattern composite design pattern for more information
*/
class CompositeFlushDiscoverer extends \ArrayIterator implements FlushDiscoverer
{
public function shouldFlush()
{
foreach ($this as $discoverer) {
$flush = $discoverer->shouldFlush();
if (!is_null($flush)) {
return $flush;
}
}
}
}

View File

@ -12,6 +12,8 @@ use SilverStripe\Core\Convert;
* check multiple tokens at once without having to potentially redirect the user for each of them
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class ConfirmationTokenChain
{

View File

@ -0,0 +1,120 @@
<?php
namespace SilverStripe\Core\Startup;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Environment;
/**
* Checks whether a filesystem resource has been changed since
* the manifest generation
*
* For this discoverer to get activated you should define SS_FLUSH_ON_DEPLOY
* variable
* - if the environment variable SS_FLUSH_ON_DEPLOY undefined or `false`, then does nothing
* - if SS_FLUSH_ON_DEPLOY is true, then checks __FILE__ modification time
* - otherwise takes {BASE_PATH/SS_FLUSH_ON_DEPLOY} as the resource to check
*
* Examples:
*
* - `SS_FLUSH_ON_DEPLOY=""` would check the BASE_PATH folder for modifications (not the files within)
* - `SS_FLUSH_ON_DEPLOY=true` would check BASE_PATH/vendor/silverstripe/framework/src/Core/Startup/DeployFlushDiscoverer.php
* file modification
* - `SS_FLUSH_ON_DEPLOY=false` disable filesystem checks
* - `SS_FLUSH_ON_DEPLOY="public/index.php"` checks BASE_PATH/public/index.php file modification time
*/
class DeployFlushDiscoverer implements FlushDiscoverer
{
/**
* Active kernel
*
* @var Kernel
*/
protected $kernel;
public function __construct(Kernel $kernel)
{
$this->kernel = $kernel;
}
/**
* Returns the timestamp of the manifest generation or null
* if no cache has been found (or couldn't read the cache)
*
* @return int|null unix timestamp
*/
protected function getCacheTimestamp()
{
$classLoader = $this->kernel->getClassLoader();
$classManifest = $classLoader->getManifest();
$cacheTimestamp = $classManifest->getManifestTimestamp();
return $cacheTimestamp;
}
/**
* Returns the resource to be checked for deployment
*
* - if the environment variable SS_FLUSH_ON_DEPLOY undefined or false, then returns null
* - if SS_FLUSH_ON_DEPLOY is true, then takes __FILE__ as the resource to check
* - otherwise takes {BASE_PATH/SS_FLUSH_ON_DEPLOY} as the resource to check
*
* @return string|null returns the resource path or null if not set
*/
protected function getDeployResource()
{
$resource = Environment::getEnv('SS_FLUSH_ON_DEPLOY');
if ($resource === false) {
return null;
}
if ($resource === true) {
$path = __FILE__;
} else {
$path = sprintf("%s/%s", BASE_PATH, $resource);
}
return $path;
}
/**
* Returns the resource modification timestamp
*
* @param string $resource Path to the filesystem
*
* @return int
*/
protected function getDeployTimestamp($resource)
{
if (!file_exists($resource)) {
return 0;
}
return max(filemtime($resource), filectime($resource));
}
/**
* Returns true if the deploy timestamp greater than the cache generation timestamp
*
* {@inheritdoc}
*/
public function shouldFlush()
{
$resource = $this->getDeployResource();
if (is_null($resource)) {
return null;
}
$deploy = $this->getDeployTimestamp($resource);
$cache = $this->getCacheTimestamp();
if ($deploy && $cache && $deploy > $cache) {
return true;
}
return null;
}
}

View File

@ -16,6 +16,8 @@ use Exception;
* $chain->then($callback1)->then($callback2)->thenIfErrored($callback3)->execute();
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class ErrorControlChain
{

View File

@ -8,12 +8,15 @@ use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\Middleware\HTTPMiddleware;
use SilverStripe\Core\Application;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Security\Security;
/**
* Decorates application bootstrapping with errorcontrolchain
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class ErrorControlChainMiddleware implements HTTPMiddleware
{
@ -22,14 +25,24 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
*/
protected $application = null;
/**
* Whether to keep working (legacy mode)
*
* @var bool
*/
private $legacy;
/**
* Build error control chain for an application
*
* @param Application $application
* @param bool $legacy Keep working (legacy mode)
*/
public function __construct(Application $application)
public function __construct(Application $application, $legacy = false)
{
$this->application = $application;
$this->legacy = $legacy;
Deprecation::notice('5.0', 'ErrorControlChainMiddleware is deprecated and will be removed completely');
}
/**
@ -50,6 +63,10 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
public function process(HTTPRequest $request, callable $next)
{
if (!$this->legacy) {
return call_user_func($next, $request);
}
$result = null;
// Prepare tokens and execute chain
@ -108,7 +125,7 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
// Ensure session is started
$request->getSession()->init($request);
// Request with ErrorDirector
$result = ErrorDirector::singleton()->handleRequestWithTokenChain(
$request,

View File

@ -14,6 +14,8 @@ use SilverStripe\Security\Security;
* Specialised Director class used by ErrorControlChain to handle error and redirect conditions
*
* @internal This class is experimental API and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class ErrorDirector extends Director
{

View File

@ -0,0 +1,20 @@
<?php
namespace SilverStripe\Core\Startup;
/**
* Public interface for startup flush discoverers
*/
interface FlushDiscoverer
{
/**
* Check whether we have to flush manifest
*
* The return value is either null or a bool
* - null means the discoverer does not override the default behaviour (other discoverers decision)
* - bool means the discoverer wants to force flush or prevent it (true or false respectively)
*
* @return null|bool null if don't care or bool to force or prevent flush
*/
public function shouldFlush();
}

View File

@ -15,6 +15,8 @@ use SilverStripe\Security\RandomGenerator;
* redirected URL
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class ParameterConfirmationToken extends AbstractConfirmationToken
{
@ -24,7 +26,7 @@ class ParameterConfirmationToken extends AbstractConfirmationToken
* @var string
*/
protected $parameterName = null;
/**
* The parameter given in the main request
*
@ -124,7 +126,7 @@ class ParameterConfirmationToken extends AbstractConfirmationToken
// Don't reload if token exists
return $this->reloadRequired() || $this->existsInReferer();
}
public function suppress()
{
unset($_GET[$this->parameterName]);
@ -153,7 +155,7 @@ class ParameterConfirmationToken extends AbstractConfirmationToken
? $this->params()
: array_merge($this->request->getVars(), $this->params());
}
protected function redirectURL()
{
$query = http_build_query($this->getRedirectUrlParams());

View File

@ -0,0 +1,88 @@
<?php
namespace SilverStripe\Core\Startup;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Environment;
/**
* The default flush discovery implementation
*
* - if request has `flush` or URL is `dev/build`
* - AND in CLI or DEV mode
* - then flush
*/
class RequestFlushDiscoverer implements FlushDiscoverer
{
/**
* Environment type (dev, test or live)
*
* @var string
*/
protected $env;
/**
* Active request instance (session is not initialized yet!)
*
* @var HTTPRequest
*/
protected $request;
/**
* Initialize it with active Request and Kernel
*
* @param HTTPRequest $request instance of the request (session is not initialized yet!)
* @param string $env Environment type (dev, test or live)
*/
public function __construct(HTTPRequest $request, $env)
{
$this->env = $env;
$this->request = $request;
}
/**
* Checks whether the request contains any flush indicators
*
* @param HTTPRequest $request active request
*
* @return null|bool flush or don't care
*/
protected function lookupRequest()
{
$request = $this->request;
$getVar = array_key_exists('flush', $request->getVars());
$devBuild = $request->getURL() === 'dev/build';
// WARNING!
// We specifically return `null` and not `false` here so that
// it does not override other FlushDiscoverers
return ($getVar || $devBuild) ? true : null;
}
/**
* Checks for permission to flush
*
* Startup flush through a request is only allowed
* to CLI or DEV modes for security reasons
*
* @return bool|null true for allow, false for denying, or null if don't care
*/
protected function isAllowed()
{
// WARNING!
// We specifically return `null` and not `false` here so that
// it does not override other FlushDiscoverers
return (Environment::isCli() || $this->env === Kernel::DEV) ? true : null;
}
public function shouldFlush()
{
if (!$allowed = $this->isAllowed()) {
return $allowed;
}
return $this->lookupRequest();
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace SilverStripe\Core\Startup;
use SilverStripe\Core\Kernel;
/**
* Checks the manifest cache for flush being scheduled in a
* previous request
*/
class ScheduledFlushDiscoverer implements FlushDiscoverer
{
/**
* Active kernel
*
* @var Kernel
*/
protected $kernel;
public function __construct(Kernel $kernel)
{
$this->kernel = $kernel;
}
/**
* Returns the flag whether the manifest flush
* has been scheduled in previous requests
*
* @return bool unix timestamp
*/
protected function getFlush()
{
$classLoader = $this->kernel->getClassLoader();
$classManifest = $classLoader->getManifest();
return (bool) $classManifest->isFlushScheduled();
}
/**
* @internal This method is not a part of public API and will be deleted without a deprecation warning
*
* This method is here so that scheduleFlush functionality implementation is kept close to the check
* implementation.
*/
public static function scheduleFlush(Kernel $kernel)
{
$classLoader = $kernel->getClassLoader();
$classManifest = $classLoader->getManifest();
if (!$classManifest->isFlushScheduled()) {
$classManifest->scheduleFlush();
return true;
}
return false;
}
public function shouldFlush()
{
if ($this->getFlush()) {
return true;
}
return null;
}
}

View File

@ -11,6 +11,8 @@ use SilverStripe\Control\HTTPRequest;
* by generating a one-time-use token & redirecting with that token included in the redirected URL
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*
* @deprecated 5.0 To be removed in SilverStripe 5.0
*/
class URLConfirmationToken extends AbstractConfirmationToken
{

View File

@ -0,0 +1,33 @@
<?php
namespace SilverStripe\Dev;
use SilverStripe\Control\Director;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\Security\Confirmation;
/**
* A simple controller using DebugView to wrap up the confirmation form
* with a template similar to other DevelopmentAdmin endpoints and UIs
*
* This is done particularly for the confirmation of URL special parameters
* and /dev/build, so that people opening the confirmation form wouldn't
* mix it up with some non-dev functionality
*/
class DevConfirmationController extends Confirmation\Handler
{
public function index()
{
$response = parent::index();
$renderer = DebugView::create();
echo $renderer->renderHeader();
echo $renderer->renderInfo(
_t(__CLASS__.".INFO_TITLE", "Security Confirmation"),
Director::absoluteBaseURL(),
_t(__CLASS__.".INFO_DESCRIPTION", "Confirm potentially dangerous operation")
);
return $response;
}
}

View File

@ -52,10 +52,22 @@ class DevelopmentAdmin extends Controller
*/
private static $allow_all_cli = true;
/**
* Deny all non-cli requests (browser based ones) to dev admin
*
* @config
* @var bool
*/
private static $deny_non_cli = false;
protected function init()
{
parent::init();
if (static::config()->get('deny_non_cli') && !Director::is_cli()) {
return $this->httpError(404);
}
// Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957)
$requestedDevBuild = (stripos($this->getRequest()->getURL(), 'dev/build') === 0)
&& (stripos($this->getRequest()->getURL(), 'dev/build/defaults') === false);

View File

@ -13,10 +13,10 @@ use SilverStripe\Core\CoreKernel;
use SilverStripe\Core\EnvironmentLoader;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Path;
use SilverStripe\Core\Startup\ParameterConfirmationToken;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\Security\DefaultAdminService;
use SilverStripe\Security\Security;
use SilverStripe\Control\Middleware\URLSpecialsMiddleware\SessionEnvTypeSwitcher;
/**
* This installer doesn't use any of the fancy SilverStripe stuff in case it's unsupported.
@ -24,6 +24,7 @@ use SilverStripe\Security\Security;
class Installer
{
use InstallEnvironmentAware;
use SessionEnvTypeSwitcher;
/**
* Errors during install
@ -203,12 +204,19 @@ PHP
// Check result of install
if (!$this->errors) {
// switch the session to Dev mode so that
// flush does not require authentication
// for the first time after installation
$request['isDev'] = '1';
$this->setSessionEnvType($request);
unset($request['isDev']);
$request->getSession()->save($request);
if (isset($_SERVER['HTTP_HOST']) && $this->hasRewritingCapability()) {
$this->statusMessage("Checking that friendly URLs work...");
$this->checkRewrite();
} else {
$token = new ParameterConfirmationToken('flush', $request);
$params = http_build_query($token->params());
$params = http_build_query($request->getVars() + ['flush' => '']);
$destinationURL = 'index.php/' .
($this->checkModuleExists('cms') ? "home/successfullyinstalled?$params" : "?$params");
@ -247,7 +255,6 @@ HTML;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Control\HTTPRequestBuilder;
use SilverStripe\Core\CoreKernel;
use SilverStripe\Core\Startup\ErrorControlChainMiddleware;
// Find autoload.php
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
@ -265,7 +272,6 @@ $request = HTTPRequestBuilder::createFromEnvironment();
// Default application
$kernel = new CoreKernel(BASE_PATH);
$app = new HTTPApplication($kernel);
$app->addMiddleware(new ErrorControlChainMiddleware($app));
$response = $app->handle($request);
$response->output();
PHP;
@ -598,8 +604,7 @@ TEXT;
public function checkRewrite()
{
$token = new ParameterConfirmationToken('flush', new HTTPRequest('GET', '/'));
$params = http_build_query($token->params());
$params = http_build_query(['flush' => '']);
$destinationURL = BASE_URL . '/' . (
$this->checkModuleExists('cms')

View File

@ -0,0 +1,171 @@
<?php
namespace SilverStripe\Security\Confirmation;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Forms\Form as BaseForm;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\FieldGroup;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\HeaderField;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\LabelField;
use SilverStripe\ORM\ValidationException;
/**
* Basic confirmation form implementation.
*
* Renders the list of confirmation items on the screen
* and reconciles those with the confirmation storage.
*
* If the user confirms the action, marks the storage as confirmed
* and redirects to the success url (kept in the storage).
*
* If the user declines the action, cleans up the storage and
* redirects to the failure url (kept in the storage).
*/
class Form extends BaseForm
{
/**
* Confirmation storage instance
*
* @var Storage
*/
private $storage;
/**
* @param string $storageId confirmation storage identifier
* @param RequestHandler $controller active request handler
* @param string $formConstructor form constructor name
*/
public function __construct($storageId, RequestHandler $controller, $formConstructor)
{
$request = $controller->getRequest();
$storage = Injector::inst()->createWithArgs(Storage::class, [$request->getSession(), $storageId, false]);
if (count($storage->getItems())) {
$fieldList = $this->buildFieldList($storage);
$actionList = $this->buildActionList($storage);
} else {
$fieldList = $this->buildEmptyFieldList();
$actionList = null;
}
parent::__construct($controller, $formConstructor, $fieldList, $actionList);
if ($storage->getHttpMethod() !== 'POST') {
$this->enableSecurityToken();
}
$this->storage = $storage;
}
/**
* The form refusal handler. Cleans up the confirmation storage
* and returns the failure redirection (kept in the storage)
*
* @return HTTPResponse redirect
*/
public function doRefuse()
{
$url = $this->storage->getFailureUrl();
$this->storage->cleanup();
return $this->controller->redirect($url);
}
/**
* The form confirmation handler. Checks all the items in the storage
* has been confirmed and marks them as such. Returns a redirect
* when all the storage items has been verified and marked as confirmed.
*
* @return HTTPResponse success url
*
* @throws ValidationException when the confirmation storage has an item missing on the form
*/
public function doConfirm()
{
$storage = $this->storage;
$data = $this->getData();
if (!$storage->confirm($data)) {
throw new ValidationException('Sorry, we could not verify the parameters');
}
$url = $storage->getSuccessUrl();
return $this->controller->redirect($url);
}
protected function buildActionList(Storage $storage)
{
$cancel = FormAction::create('doRefuse', _t(__CLASS__.'.REFUSE', 'Cancel'));
$confirm = FormAction::create('doConfirm', _t(__CLASS__.'.CONFIRM', 'Run the action'))->setAutofocus(true);
if ($storage->getHttpMethod() === 'POST') {
$confirm->setAttribute('formaction', htmlspecialchars($storage->getSuccessUrl()));
}
return FieldList::create($cancel, $confirm);
}
/**
* Builds the form fields taking the confirmation items from the storage
*
* @param Storage $storage Confirmation storage instance
*
* @return FieldList
*/
protected function buildFieldList(Storage $storage)
{
$fields = [];
foreach ($storage->getItems() as $item) {
$group = [];
$group[] = HeaderField::create(null, $item->getName());
if ($item->getDescription()) {
$group[] = LabelField::create($item->getDescription());
}
$fields[] = FieldGroup::create(...$group);
}
foreach ($storage->getHashedItems() as $key => $val) {
$fields[] = HiddenField::create($key, null, $val);
}
if ($storage->getHttpMethod() === 'POST') {
// add the storage CSRF token
$fields[] = HiddenField::create($storage->getCsrfToken(), null, '1');
// replicate the original POST request parameters
// so that the new confirmed POST request has those
$data = $storage->getSuccessPostVars();
if (is_null($data)) {
throw new ValidationException('Sorry, your cookies seem to have expired. Try to repeat the initial action.');
}
foreach ($data as $key => $value) {
$fields[] = HiddenField::create($key, null, $value);
}
}
return FieldList::create(...$fields);
}
/**
* Builds the fields showing the form is empty and there's nothing
* to confirm
*
* @return FieldList
*/
protected function buildEmptyFieldList()
{
return FieldList::create(
HeaderField::create(null, _t(__CLASS__.'.EMPTY_TITLE', 'Nothing to confirm'))
);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace SilverStripe\Security\Confirmation;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Forms\Form as BaseForm;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\RequiredFields;
/**
* Confirmation form handler implementation
*
* Handles StorageID identifier in the URL
*/
class Handler extends RequestHandler
{
private static $url_handlers = [
'$StorageID!/$Action//$ID/$OtherID' => '$Action',
];
private static $allowed_actions = [
'index',
'Form'
];
public function Link($action = null)
{
$request = Injector::inst()->get(HTTPRequest::class);
$link = Controller::join_links(Director::baseURL(), $request->getUrl(), $action);
$this->extend('updateLink', $link, $action);
return $link;
}
/**
* URL handler for the log-in screen
*
* @return array
*/
public function index()
{
return [
'Title' => _t(__CLASS__.'.FORM_TITLE', 'Confirm potentially dangerous action'),
'Form' => $this->Form()
];
return $this;
}
/**
* This method is being used by Form to check whether it needs to use SecurityToken
*
* We always return false here as the confirmation form should decide this on its own
* depending on the Storage data. If we had the original request to
* be POST with its own SecurityID, we don't want to interfre with it. If it's been
* GET request, then it will generate a new SecurityToken
*
* @return bool
*/
public function securityTokenEnabled()
{
return false;
}
/**
* Returns an instance of Confirmation\Form initialized
* with the proper storage id taken from URL
*
* @return Form
*/
public function Form()
{
$storageId = $this->request->param('StorageID');
if (!strlen(trim($storageId))) {
$this->httpError(404, "Undefined StorageID");
}
return Form::create($storageId, $this, __FUNCTION__);
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace SilverStripe\Security\Confirmation;
/**
* Confirmation item is a simple data object
* incapsulating a single confirmation unit,
* its unique identifier (token), its human
* friendly name, description and the status
* whether it has already been confirmed.
*/
class Item
{
/**
* A confirmation token which is
* unique for every confirmation item
*
* @var string
*/
private $token;
/**
* Human readable item name
*
* @var string
*/
private $name;
/**
* Human readable description of the item
*
* @var string
*/
private $description;
/**
* Whether the item has been confirmed or not
*
* @var bool
*/
private $confirmed;
/**
* @param string $token unique token of this confirmation item
* @param string $name Human readable name of the item
* @param string $description Human readable description of the item
*/
public function __construct($token, $name, $description)
{
$this->token = $token;
$this->name = $name;
$this->description = $description;
$this->confirmed = false;
}
/**
* Returns the token of the item
*
* @return string
*/
public function getToken()
{
return $this->token;
}
/**
* Returns the item name (human readable)
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Returns the human readable description of the item
*
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Returns whether the item has been confirmed
*
* @return bool
*/
public function isConfirmed()
{
return $this->confirmed;
}
/**
* Mark the item as confirmed
*/
public function confirm()
{
$this->confirmed = true;
}
}

View File

@ -0,0 +1,444 @@
<?php
namespace SilverStripe\Security\Confirmation;
use SilverStripe\Control\Cookie;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Security\SecurityToken;
/**
* Confirmation Storage implemented on top of SilverStripe Session and Cookie
*
* The storage keeps the information about the items requiring
* confirmation and their status (confirmed or not) in Session
*
* User data, such as the original request parameters, may be kept in
* Cookie so that session storage cannot be exhausted easily by a malicious user
*/
class Storage
{
const HASH_ALGO = 'sha512';
/**
* @var \SilverStripe\Control\Session
*/
protected $session;
/**
* Identifier of the storage within the session
*
* @var string
*/
protected $id;
/**
* @param Session $session active session
* @param string $id Unique storage identifier within the session
* @param bool $new Cleanup the storage
*/
public function __construct(Session $session, $id, $new = true)
{
$id = trim((string) $id);
if (!strlen($id)) {
throw new \InvalidArgumentException('Storage ID must not be empty');
}
$this->session = $session;
$this->id = $id;
if ($new) {
$this->cleanup();
}
}
/**
* Remove all the data from the storage
* Cleans up Session and Cookie related to this storage
*/
public function cleanup()
{
Cookie::force_expiry($this->getCookieKey());
$this->session->clear($this->getNamespace());
}
/**
* Gets user input data (usually POST array), checks all the items in the storage
* has been confirmed and marks them as such.
*
* @param array $data User input to look at for items. Usually POST array
*
* @return bool whether all items have been confirmed
*/
public function confirm($data)
{
foreach ($this->getItems() as $item) {
$key = base64_encode($this->getTokenHash($item));
if (!isset($data[$key]) || $data[$key] !== '1') {
return false;
}
$item->confirm();
$this->putItem($item);
}
return true;
}
/**
* Returns the dictionary with the item hashes
*
* The {@see SilverStripe\Security\Confirmation\Storage::confirm} function
* expects exactly same dictionary as its argument for successful confirmation
*
* Keys of the dictionary are salted item token hashes
* All values are the string "1" constantly
*
* @return array
*/
public function getHashedItems()
{
$items = [];
foreach ($this->getItems() as $item) {
$hash = base64_encode($this->getTokenHash($item));
$items[$hash] = '1';
}
return $items;
}
/**
* Returns salted and hashed version of the item token
*
* @param Item $item
*
* @return string
*/
public function getTokenHash(Item $item)
{
$token = $item->getToken();
$salt = $this->getSessionSalt();
$salted = $salt.$token;
return hash(static::HASH_ALGO, $salted, true);
}
/**
* Returns the unique cookie key generated from the session salt
*
* @return string
*/
public function getCookieKey()
{
$salt = $this->getSessionSalt();
return bin2hex(hash(static::HASH_ALGO, $salt.'cookie key', true));
}
/**
* Returns a unique token to use as a CSRF token
*
* @return string
*/
public function getCsrfToken()
{
$salt = $this->getSessionSalt();
return base64_encode(hash(static::HASH_ALGO, $salt.'csrf token', true));
}
/**
* Returns the salt generated for the current session
*
* @return string
*/
public function getSessionSalt()
{
$key = $this->getNamespace('salt');
if (!$salt = $this->session->get($key)) {
$salt = $this->generateSalt();
$this->session->set($key, $salt);
}
return $salt;
}
/**
* Returns randomly generated salt
*
* @return string
*/
protected function generateSalt()
{
return random_bytes(64);
}
/**
* Adds a new object to the list of confirmation items
* Replaces the item if there is already one with the same token
*
* @param Item $item Item requiring confirmation
*
* @return $this
*/
public function putItem(Item $item)
{
$key = $this->getNamespace('items');
$items = $this->session->get($key) ?: [];
$token = $this->getTokenHash($item);
$items[$token] = $item;
$this->session->set($key, $items);
return $this;
}
/**
* Returns the list of registered confirmation items
*
* @return Item[]
*/
public function getItems()
{
return $this->session->get($this->getNamespace('items')) ?: [];
}
/**
* Look up an item by its token key
*
* @param string $key Item token key
*
* @return null|Item
*/
public function getItem($key)
{
foreach ($this->getItems() as $item) {
if ($item->getToken() === $key) {
return $item;
}
}
}
/**
* This request should be performed on success
* Usually the original request which triggered the confirmation
*
* @param HTTPRequest $request
*
* @return $this
*/
public function setSuccessRequest(HTTPRequest $request)
{
$this->setSuccessUrl($request->getURL(true));
$httpMethod = $request->httpMethod();
$this->session->set($this->getNamespace('httpMethod'), $httpMethod);
if ($httpMethod === 'POST') {
$checksum = $this->setSuccessPostVars($request->postVars());
$this->session->set($this->getNamespace('postChecksum'), $checksum);
}
}
/**
* Save the post data in the storage (browser Cookies by default)
* Returns the control checksum of the data preserved
*
* Keeps data in Cookies to avoid potential DDoS targeting
* session storage exhaustion
*
* @param array $data
*
* @return string checksum
*/
protected function setSuccessPostVars(array $data)
{
$checksum = hash_init(static::HASH_ALGO);
$cookieData = [];
foreach ($data as $key => $val) {
$key = strval($key);
$val = strval($val);
hash_update($checksum, $key);
hash_update($checksum, $val);
$cookieData[] = [$key, $val];
}
$checksum = hash_final($checksum, true);
$cookieData = json_encode($cookieData, 0, 2);
$cookieKey = $this->getCookieKey();
Cookie::set($cookieKey, $cookieData, 0);
return $checksum;
}
/**
* Returns HTTP method of the success request
*
* @return string
*/
public function getHttpMethod()
{
return $this->session->get($this->getNamespace('httpMethod'));
}
/**
* Returns the list of success request post parameters
*
* Returns null if no parameters was persisted initially or
* if the checksum is incorrect.
*
* WARNING! If HTTP Method is POST and this function returns null,
* you MUST assume the Cookie parameter either has been forged or
* expired.
*
* @return array|null
*/
public function getSuccessPostVars()
{
$controlChecksum = $this->session->get($this->getNamespace('postChecksum'));
if (!$controlChecksum) {
return null;
}
$cookieKey = $this->getCookieKey();
$cookieData = Cookie::get($cookieKey);
if (!$cookieData) {
return null;
}
$cookieData = json_decode($cookieData, true, 3);
if (!is_array($cookieData)) {
return null;
}
$checksum = hash_init(static::HASH_ALGO);
$data = [];
foreach ($cookieData as $pair) {
if (!isset($pair[0]) || !isset($pair[1])) {
return null;
}
$key = $pair[0];
$val = $pair[1];
hash_update($checksum, $key);
hash_update($checksum, $val);
$data[$key] = $val;
}
$checksum = hash_final($checksum, true);
if ($checksum !== $controlChecksum) {
return null;
}
return $data;
}
/**
* The URL the form should redirect to on success
*
* @param string $url Success URL
*
* @return $this;
*/
public function setSuccessUrl($url)
{
$this->session->set($this->getNamespace('successUrl'), $url);
return $this;
}
/**
* Returns the URL registered by {@see self::setSuccessUrl} as a success redirect target
*
* @return string
*/
public function getSuccessUrl()
{
return $this->session->get($this->getNamespace('successUrl'));
}
/**
* The URL the form should redirect to on failure
*
* @param string $url Failure URL
*
* @return $this;
*/
public function setFailureUrl($url)
{
$this->session->set($this->getNamespace('failureUrl'), $url);
return $this;
}
/**
* Returns the URL registered by {@see self::setFailureUrl} as a success redirect target
*
* @return string
*/
public function getFailureUrl()
{
return $this->session->get($this->getNamespace('failureUrl'));
}
/**
* Check all items to be confirmed in the storage
*
* @param Item[] $items List of items to be checked
*
* @return bool
*/
public function check(array $items)
{
foreach ($items as $itemToConfirm) {
foreach ($this->getItems() as $item) {
if ($item->getToken() !== $itemToConfirm->getToken()) {
continue;
}
if ($item->isConfirmed()) {
continue 2;
}
break;
}
return false;
}
return true;
}
/**
* Returns the namespace of the storage in the session
*
* @param string|null $key Optional key within the storage
*
* @return string
*/
protected function getNamespace($key = null)
{
return sprintf(
'%s.%s%s',
str_replace('\\', '.', __CLASS__),
$this->id,
$key ? '.'.$key : ''
);
}
}

View File

@ -37,13 +37,13 @@ class Security extends Controller implements TemplateGlobalProvider
{
private static $allowed_actions = array(
'basicauthlogin',
'changepassword',
'index',
'login',
'logout',
'basicauthlogin',
'lostpassword',
'passwordsent',
'changepassword',
'ping',
);
@ -661,7 +661,6 @@ class Security extends Controller implements TemplateGlobalProvider
->clear("Security.Message");
}
/**
* Show the "login" page
*

View File

@ -13,6 +13,7 @@ use SilverStripe\Control\RequestProcessor;
use SilverStripe\Control\Tests\DirectorTest\TestController;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Kernel;
use SilverStripe\Dev\SapphireTest;
@ -25,11 +26,15 @@ class DirectorTest extends SapphireTest
TestController::class,
];
private $originalEnvType;
protected function setUp()
{
parent::setUp();
Director::config()->set('alternate_base_url', 'http://www.mysite.com:9090/');
$this->originalEnvType = Environment::getEnv('SS_ENVIRONMENT_TYPE');
// Ensure redirects enabled on all environments and global state doesn't affect the tests
CanonicalURLMiddleware::singleton()
->setForceSSLDomain(null)
@ -38,6 +43,12 @@ class DirectorTest extends SapphireTest
$this->expectedRedirect = null;
}
protected function tearDown(...$args)
{
Environment::setEnv('SS_ENVIRONMENT_TYPE', $this->originalEnvType);
parent::tearDown(...$args);
}
protected function getExtraRoutes()
{
$rules = parent::getExtraRoutes();
@ -403,7 +414,7 @@ class DirectorTest extends SapphireTest
}
/**
* Tests isDev, isTest, isLive set from querystring
* Tests isDev, isTest, isLive cannot be set from querystring
*/
public function testQueryIsEnvironment()
{
@ -419,30 +430,33 @@ class DirectorTest extends SapphireTest
/** @var Kernel $kernel */
$kernel = Injector::inst()->get(Kernel::class);
$kernel->setEnvironment(null);
Environment::setEnv('SS_ENVIRONMENT_TYPE', Kernel::LIVE);
$this->assertTrue(Director::isLive());
// Test isDev=1
$_GET['isDev'] = '1';
$this->assertTrue(Director::isDev());
$this->assertFalse(Director::isDev());
$this->assertFalse(Director::isTest());
$this->assertFalse(Director::isLive());
$this->assertTrue(Director::isLive());
// Test persistence
unset($_GET['isDev']);
$this->assertTrue(Director::isDev());
$this->assertFalse(Director::isDev());
$this->assertFalse(Director::isTest());
$this->assertFalse(Director::isLive());
$this->assertTrue(Director::isLive());
// Test change to isTest
$_GET['isTest'] = '1';
$this->assertFalse(Director::isDev());
$this->assertTrue(Director::isTest());
$this->assertFalse(Director::isLive());
$this->assertFalse(Director::isTest());
$this->assertTrue(Director::isLive());
// Test persistence
unset($_GET['isTest']);
$this->assertFalse(Director::isDev());
$this->assertTrue(Director::isTest());
$this->assertFalse(Director::isLive());
$this->assertFalse(Director::isTest());
$this->assertTrue(Director::isLive());
}
public function testResetGlobalsAfterTestRequest()

View File

@ -2,6 +2,8 @@
namespace SilverStripe\Control\Tests;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel;
use SilverStripe\Control\Tests\FlushMiddlewareTest\TestFlushable;
use SilverStripe\Dev\FunctionalTest;
@ -13,7 +15,12 @@ class FlushMiddlewareTest extends FunctionalTest
public function testImplementorsAreCalled()
{
TestFlushable::$flushed = false;
$this->get('?flush=1');
Injector::inst()->get(Kernel::class)->boot(true);
$this->get('/');
$this->assertTrue(TestFlushable::$flushed);
// reset the kernel Flush flag
Injector::inst()->get(Kernel::class)->boot();
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace SilverStripe\Control\Tests;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
trait HttpRequestMockBuilder
{
/**
* Builds and returns a new mock instance of HTTPRequest
*
* @param string $url
* @param array $getVars GET parameters
* @param array $postVars POST parameters
* @param string|null $method HTTP method
* @param Session|null $session Session instance
*
* @return HTTPRequest
*/
public function buildRequestMock($url, $getVars = [], $postVars = [], $method = null, Session $session = null)
{
if (is_null($session)) {
$session = new Session([]);
}
$request = $this->createMock(HTTPRequest::class);
$request->method('getSession')->willReturn($session);
$request->method('getURL')->will($this->returnCallback(static function ($addParams) use ($url, $getVars) {
return $addParams && count($getVars) ? $url.'?'.http_build_query($getVars) : $url;
}));
$request->method('getVars')->willReturn($getVars);
$request->method('getVar')->will($this->returnCallback(static function ($key) use ($getVars) {
return isset($getVars[$key]) ? $getVars[$key] : null;
}));
$request->method('postVars')->willReturn($postVars);
$request->method('postVar')->will($this->returnCallback(static function ($key) use ($postVars) {
return isset($postVars[$key]) ? $postVars[$key] : null;
}));
if (is_null($method)) {
if (count($postVars)) {
$method = 'POST';
} else {
$method = 'GET';
}
}
$request->method('httpMethod')->willReturn($method);
return $request;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\AjaxBypass;
use SilverStripe\Dev\SapphireTest;
class AjaxBypassTest extends SapphireTest
{
public function testBypass()
{
$ajaxRequest = $this->createMock(HTTPRequest::class);
$ajaxRequest->method('isAjax')->willReturn(true);
$simpleRequest = $this->createMock(HTTPRequest::class);
$simpleRequest->method('isAjax')->willReturn(false);
$ajaxBypass = new AjaxBypass();
$this->assertFalse($ajaxBypass->checkRequestForBypass($simpleRequest));
$this->assertTrue($ajaxBypass->checkRequestForBypass($ajaxRequest));
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\GetParameter;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Confirmation\Item;
class GetParameterTest extends SapphireTest
{
use HttpRequestMockBuilder;
public function testName()
{
$rule = new GetParameter('name_01');
$this->assertEquals('name_01', $rule->getName());
$rule->setName('name_02');
$this->assertEquals('name_02', $rule->getName());
}
public function testBypass()
{
$request = $this->buildRequestMock('test/path', ['parameterKey' => 'parameterValue']);
$rule = new GetParameter('parameterKey_01');
$this->assertFalse($rule->checkRequestForBypass($request));
$rule->setName('parameterKey');
$this->assertTrue($rule->checkRequestForBypass($request));
}
public function testConfirmationItem()
{
$request = $this->buildRequestMock('test/path', ['parameterKey' => 'parameterValue']);
$rule = new GetParameter('parameterKey_01');
$this->assertNull($rule->getRequestConfirmationItem($request));
$rule->setName('parameterKey');
$item = $rule->getRequestConfirmationItem($request);
$this->assertNotNull($item);
$this->assertInstanceOf(Item::class, $item);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass;
use SilverStripe\Dev\SapphireTest;
class HttpMethodBypassTest extends SapphireTest
{
public function testBypass()
{
$getRequest = $this->createMock(HTTPRequest::class);
$getRequest->method('httpMethod')->willReturn('GET');
$postRequest = $this->createMock(HTTPRequest::class);
$postRequest->method('httpMethod')->willReturn('POST');
$putRequest = $this->createMock(HTTPRequest::class);
$putRequest->method('httpMethod')->willReturn('PUT');
$delRequest = $this->createMock(HTTPRequest::class);
$delRequest->method('httpMethod')->willReturn('DELETE');
$bypass = new HttpMethodBypass('GET', 'POST');
$this->assertTrue($bypass->checkRequestForBypass($getRequest));
$this->assertTrue($bypass->checkRequestForBypass($postRequest));
$this->assertFalse($bypass->checkRequestForBypass($putRequest));
$this->assertFalse($bypass->checkRequestForBypass($delRequest));
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswithCaseInsensitive;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Confirmation\Item;
class UrlPathStartswithCaseInsensitiveTest extends SapphireTest
{
use HttpRequestMockBuilder;
public function testPath()
{
$url = new UrlPathStartswithCaseInsensitive('test/path_01');
$this->assertEquals('test/path_01/', $url->getPath());
$url->setPath('test/path_02');
$this->assertEquals('test/path_02/', $url->getPath());
}
public function testBypass()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new UrlPathStartswithCaseInsensitive('dev');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('dev/');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('dev/build');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('dev/build/');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('de');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('dev/buil');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('Dev');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswithCaseInsensitive('dev/builD');
$this->assertTrue($url->checkRequestForBypass($request));
}
public function testConfirmationItem()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new UrlPathStartswithCaseInsensitive('dev');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('dev/');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('dev/build');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('dev/build/');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('de');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('dev/buil');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('Dev/build');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswithCaseInsensitive('dev/builD');
$this->assertNotNull($url->getRequestConfirmationItem($request));
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Confirmation\Item;
class UrlPathStartswithTest extends SapphireTest
{
use HttpRequestMockBuilder;
public function testPath()
{
$url = new UrlPathStartswith('test/path_01');
$this->assertEquals('test/path_01/', $url->getPath());
$url->setPath('test/path_02');
$this->assertEquals('test/path_02/', $url->getPath());
}
public function testBypass()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new UrlPathStartswith('dev');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('dev/');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('dev/build');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('dev/build/');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('de');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('dev/buil');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('Dev');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new UrlPathStartswith('dev/builD');
$this->assertFalse($url->checkRequestForBypass($request));
}
public function testConfirmationItem()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new UrlPathStartswith('dev');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('dev/');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('dev/build');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('dev/build/');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('de');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('dev/buil');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('Dev/build');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new UrlPathStartswith('dev/builD');
$this->assertNull($url->getRequestConfirmationItem($request));
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\Url;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Confirmation\Item;
class UrlTest extends SapphireTest
{
use HttpRequestMockBuilder;
public function testPath()
{
$url = new Url('test/path_01');
$this->assertEquals('test/path_01/', $url->getPath());
$url->setPath('test/path_02');
$this->assertEquals('test/path_02/', $url->getPath());
}
public function testHttpMethods()
{
$url = new Url('/', ['PUT', 'DELETE']);
$this->assertCount(2, $url->getHttpMethods());
$this->assertContains('DELETE', $url->getHttpMethods());
$this->assertContains('PUT', $url->getHttpMethods());
$url->addHttpMethods('GET', 'POST');
$this->assertCount(4, $url->getHttpMethods());
$this->assertContains('DELETE', $url->getHttpMethods());
$this->assertContains('GET', $url->getHttpMethods());
$this->assertContains('POST', $url->getHttpMethods());
$this->assertContains('PUT', $url->getHttpMethods());
}
public function testBypass()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new Url('dev');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new Url('dev/build');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new Url('dev/build/');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new Url('dev/build', 'GET');
$this->assertTrue($url->checkRequestForBypass($request));
$url = new Url('dev/build', 'POST');
$this->assertFalse($url->checkRequestForBypass($request));
$url = new Url('dev/build', ['GET', 'POST']);
$this->assertTrue($url->checkRequestForBypass($request));
$url = new Url('dev/build', null, []);
$this->assertFalse($url->checkRequestForBypass($request));
$url = new Url('dev/build', null, ['flush' => null]);
$this->assertTrue($url->checkRequestForBypass($request));
$url = new Url('dev/build', null, ['flush' => '1']);
$this->assertFalse($url->checkRequestForBypass($request));
$url = new Url('dev/build', null, ['flush' => 'all']);
$this->assertTrue($url->checkRequestForBypass($request));
}
public function testConfirmationItem()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$url = new Url('dev');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build/');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', 'GET');
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', 'POST');
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', ['GET', 'POST']);
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', null, []);
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', null, ['flush' => null]);
$this->assertNotNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', null, ['flush' => '1']);
$this->assertNull($url->getRequestConfirmationItem($request));
$url = new Url('dev/build', null, ['flush' => 'all']);
$this->assertNotNull($url->getRequestConfirmationItem($request));
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace SilverStripe\Control\Tests\Middleware;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Middleware\ConfirmationMiddleware\Url;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
use SilverStripe\Dev\SapphireTest;
class ConfirmationMiddlewareTest extends SapphireTest
{
use HttpRequestMockBuilder;
public function testBypass()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$middleware = new ConfirmationMiddleware(new Url('dev/build'));
$this->assertFalse($middleware->canBypass($request));
$middleware->setBypasses([new Url('no-match')]);
$this->assertFalse($middleware->canBypass($request));
$middleware->setBypasses([new Url('dev/build')]);
$this->assertTrue($middleware->canBypass($request));
}
public function testConfirmationItems()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$middleware = new ConfirmationMiddleware(
new Url('dev/build'),
new Url('dev/build', null, ['flush' => null])
);
$items = $middleware->getConfirmationItems($request);
$this->assertCount(2, $items);
}
public function testProcess()
{
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
// Testing the middleware does not do anything if rules do not apply
$middleware = new ConfirmationMiddleware(new Url('no-match'));
$next = false;
$middleware->process(
$request,
static function () use (&$next) {
$next = true;
}
);
$this->assertTrue($next);
// Test for a redirection when rules hit the request
$middleware = new ConfirmationMiddleware(new Url('dev/build'));
$next = false;
$response = $middleware->process(
$request,
static function () use (&$next) {
$next = true;
}
);
$this->assertFalse($next);
$this->assertInstanceOf(HTTPResponse::class, $response);
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('/dev/confirm/middleware', $response->getHeader('location'));
// Test bypasses have more priority than rules
$middleware->setBypasses([new Url('dev/build')]);
$next = false;
$response = $middleware->process(
$request,
static function () use (&$next) {
$next = true;
}
);
}
}

View File

@ -1,187 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use SilverStripe\Core\Startup\ConfirmationTokenChain;
use SilverStripe\Core\Startup\ParameterConfirmationToken;
use SilverStripe\Core\Startup\URLConfirmationToken;
use SilverStripe\Dev\SapphireTest;
class ConfirmationTokenChainTest extends SapphireTest
{
protected function getTokenRequiringReload($requiresReload = true, $extraMethods = [])
{
$methods = array_merge(['reloadRequired'], $extraMethods);
$mock = $this->createPartialMock(ParameterConfirmationToken::class, $methods);
$mock->expects($this->any())
->method('reloadRequired')
->will($this->returnValue($requiresReload));
return $mock;
}
protected function getTokenRequiringReloadIfError($requiresReload = true, $extraMethods = [])
{
$methods = array_merge(['reloadRequired', 'reloadRequiredIfError'], $extraMethods);
$mock = $this->createPartialMock(ParameterConfirmationToken::class, $methods);
$mock->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(false));
$mock->expects($this->any())
->method('reloadRequiredIfError')
->will($this->returnValue($requiresReload));
return $mock;
}
public function testFilteredTokens()
{
$chain = new ConfirmationTokenChain();
$chain->pushToken($tokenRequiringReload = $this->getTokenRequiringReload());
$chain->pushToken($tokenNotRequiringReload = $this->getTokenRequiringReload(false));
$chain->pushToken($tokenRequiringReloadIfError = $this->getTokenRequiringReloadIfError());
$chain->pushToken($tokenNotRequiringReloadIfError = $this->getTokenRequiringReloadIfError(false));
$reflectionMethod = new \ReflectionMethod(ConfirmationTokenChain::class, 'filteredTokens');
$reflectionMethod->setAccessible(true);
$tokens = iterator_to_array($reflectionMethod->invoke($chain));
$this->assertContains($tokenRequiringReload, $tokens, 'Token requiring a reload was not returned');
$this->assertNotContains($tokenNotRequiringReload, $tokens, 'Token not requiring a reload was returned');
$this->assertContains($tokenRequiringReloadIfError, $tokens, 'Token requiring a reload on error was not returned');
$this->assertNotContains($tokenNotRequiringReloadIfError, $tokens, 'Token not requiring a reload on error was returned');
}
public function testSuppressionRequired()
{
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReload(false));
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReloadIfError(false));
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReload());
$this->assertTrue($chain->suppressionRequired(), 'Suppression not marked as required');
$chain = new ConfirmationTokenChain();
$chain->pushToken($this->getTokenRequiringReloadIfError());
$this->assertFalse($chain->suppressionRequired(), 'Suppression incorrectly marked as required');
}
public function testSuppressTokens()
{
$mockToken = $this->getTokenRequiringReload(true, ['suppress']);
$mockToken->expects($this->once())
->method('suppress');
$secondMockToken = $this->getTokenRequiringReloadIfError(true, ['suppress']);
$secondMockToken->expects($this->once())
->method('suppress');
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$chain->suppressTokens();
}
public function testReloadRequired()
{
$mockToken = $this->getTokenRequiringReload(true);
$secondMockToken = $this->getTokenRequiringReload(false);
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertTrue($chain->reloadRequired());
}
public function testReloadRequiredIfError()
{
$mockToken = $this->getTokenRequiringReloadIfError(true);
$secondMockToken = $this->getTokenRequiringReloadIfError(false);
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertTrue($chain->reloadRequiredIfError());
}
public function testParams()
{
$mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
->method('params')
->with($this->isTrue())
->will($this->returnValue(['mockTokenParam' => '1']));
$secondMockToken = $this->getTokenRequiringReload(true, ['params']);
$secondMockToken->expects($this->once())
->method('params')
->with($this->isTrue())
->will($this->returnValue(['secondMockTokenParam' => '2']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->params(true));
$mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
->method('params')
->with($this->isFalse())
->will($this->returnValue(['mockTokenParam' => '1']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$this->assertEquals(['mockTokenParam' => '1'], $chain->params(false));
}
public function testGetRedirectUrlBase()
{
$mockUrlToken = $this->createPartialMock(URLConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']);
$mockUrlToken->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(true));
$mockUrlToken->expects($this->any())
->method('getRedirectUrlBase')
->will($this->returnValue('url-base'));
$mockParameterToken = $this->createPartialMock(ParameterConfirmationToken::class, ['reloadRequired', 'getRedirectUrlBase']);
$mockParameterToken->expects($this->any())
->method('reloadRequired')
->will($this->returnValue(true));
$mockParameterToken->expects($this->any())
->method('getRedirectUrlBase')
->will($this->returnValue('parameter-base'));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockParameterToken);
$chain->pushToken($mockUrlToken);
$this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority');
// Push them in reverse order to check priority still correct
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockUrlToken);
$chain->pushToken($mockParameterToken);
$this->assertEquals('url-base', $chain->getRedirectUrlBase(), 'URLConfirmationToken url base should take priority');
}
public function testGetRedirectUrlParams()
{
$mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
->method('params')
->will($this->returnValue(['mockTokenParam' => '1']));
$secondMockToken = $this->getTokenRequiringReload(true, ['params']);
$secondMockToken->expects($this->once())
->method('params')
->will($this->returnValue(['secondMockTokenParam' => '2']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
$params = $chain->getRedirectUrlParams();
$this->assertEquals('1', $params['mockTokenParam']);
$this->assertEquals('2', $params['secondMockTokenParam']);
}
}

View File

@ -1,176 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\Session;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Startup\ErrorControlChainMiddleware;
use SilverStripe\Core\Tests\Startup\ErrorControlChainMiddlewareTest\BlankKernel;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Security;
class ErrorControlChainMiddlewareTest extends SapphireTest
{
protected $usesDatabase = true;
protected function setUp()
{
parent::setUp();
Security::force_database_is_ready(true);
}
protected function tearDown()
{
Security::clear_database_is_ready();
parent::tearDown();
}
public function testLiveFlushAdmin()
{
// Mock admin
$adminID = $this->logInWithPermission('ADMIN');
$this->logOut();
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as admin
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/', ['flush' => 1]);
$request->setSession(new Session(['loggedInAs' => $adminID]));
$result = $chain->process($request, function () {
return null;
});
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertContains('flush=1&flushtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
public function testLiveFlushUnauthenticated()
{
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as no one
Security::setCurrentUser(null);
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/', ['flush' => 1]);
$request->setSession(new Session(['loggedInAs' => 0]));
$result = $chain->process($request, function () {
return null;
});
// Should be directed to login, not to flush
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertNotContains('?flush=1&flushtoken=', $location);
$this->assertContains('Security/login', $location);
}
public function testLiveBuildAdmin()
{
// Mock admin
$adminID = $this->logInWithPermission('ADMIN');
$this->logOut();
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as admin
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build/');
$request->setSession(new Session(['loggedInAs' => $adminID]));
$result = $chain->process($request, function () {
return null;
});
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertContains('/dev/build', $location);
$this->assertContains('devbuildtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
public function testLiveBuildUnauthenticated()
{
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as no one
Security::setCurrentUser(null);
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build');
$request->setSession(new Session(['loggedInAs' => 0]));
$result = $chain->process($request, function () {
return null;
});
// Should be directed to login, not to flush
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertNotContains('/dev/build', $location);
$this->assertNotContains('?devbuildtoken=', $location);
$this->assertContains('Security/login', $location);
}
public function testLiveBuildAndFlushAdmin()
{
// Mock admin
$adminID = $this->logInWithPermission('ADMIN');
$this->logOut();
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as admin
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build/', ['flush' => '1']);
$request->setSession(new Session(['loggedInAs' => $adminID]));
$result = $chain->process($request, function () {
return null;
});
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertContains('/dev/build', $location);
$this->assertContains('flush=1', $location);
$this->assertContains('devbuildtoken=', $location);
$this->assertContains('flushtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
public function testLiveBuildAndFlushUnauthenticated()
{
// Mock app
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
$app->getKernel()->setEnvironment(Kernel::LIVE);
// Test being logged in as no one
Security::setCurrentUser(null);
$chain = new ErrorControlChainMiddleware($app);
$request = new HTTPRequest('GET', '/dev/build', ['flush' => '1']);
$request->setSession(new Session(['loggedInAs' => 0]));
$result = $chain->process($request, function () {
return null;
});
// Should be directed to login, not to flush
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertNotContains('/dev/build', $location);
$this->assertNotContains('flush=1', $location);
$this->assertNotContains('devbuildtoken=', $location);
$this->assertNotContains('flushtoken=', $location);
$this->assertContains('Security/login', $location);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\ErrorControlChainMiddlewareTest;
use SilverStripe\Core\CoreKernel;
class BlankKernel extends CoreKernel
{
public function __construct($basePath)
{
// Noop
}
public function boot($flush = false)
{
// Noop
}
}

View File

@ -1,325 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use Exception;
use Foo;
use SilverStripe\Core\Startup\ErrorControlChain;
use SilverStripe\Dev\SapphireTest;
class ErrorControlChainTest extends SapphireTest
{
protected function setUp()
{
// Check we can run PHP at all
$null = is_writeable('/dev/null') ? '/dev/null' : 'NUL';
exec("php -v 2> $null", $out, $rv);
if ($rv != 0) {
$this->markTestSkipped("Can't run PHP from the command line - is it in your path?");
}
parent::setUp();
}
public function testErrorSuppression()
{
// Errors disabled by default
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
$chain->setDisplayErrors('Off'); // mocks display_errors: Off
$initialValue = null;
$whenNotSuppressed = null;
$whenSuppressed = null;
$chain->then(function (ErrorControlChainTest\ErrorControlChainTest_Chain $chain) use (
&$initialValue,
&$whenNotSuppressed,
&$whenSuppressed
) {
$initialValue = $chain->getDisplayErrors();
$chain->setSuppression(false);
$whenNotSuppressed = $chain->getDisplayErrors();
$chain->setSuppression(true);
$whenSuppressed = $chain->getDisplayErrors();
})->execute();
// Disabled errors never un-disable
$this->assertEquals(0, $initialValue); // Chain starts suppressed
$this->assertEquals(0, $whenSuppressed); // false value used internally when suppressed
$this->assertEquals('Off', $whenNotSuppressed); // false value set by php ini when suppression lifted
$this->assertEquals('Off', $chain->getDisplayErrors()); // Correctly restored after run
// Errors enabled by default
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
$chain->setDisplayErrors('Yes'); // non-falsey ini value
$initialValue = null;
$whenNotSuppressed = null;
$whenSuppressed = null;
$chain->then(function (ErrorControlChainTest\ErrorControlChainTest_Chain $chain) use (
&$initialValue,
&$whenNotSuppressed,
&$whenSuppressed
) {
$initialValue = $chain->getDisplayErrors();
$chain->setSuppression(true);
$whenSuppressed = $chain->getDisplayErrors();
$chain->setSuppression(false);
$whenNotSuppressed = $chain->getDisplayErrors();
})->execute();
// Errors can be suppressed an un-suppressed when initially enabled
$this->assertEquals(0, $initialValue); // Chain starts suppressed
$this->assertEquals(0, $whenSuppressed); // false value used internally when suppressed
$this->assertEquals('Yes', $whenNotSuppressed); // false value set by php ini when suppression lifted
$this->assertEquals('Yes', $chain->getDisplayErrors()); // Correctly restored after run
// Fatal error
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
Foo::bar(); // Non-existant class causes fatal error
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
// User error
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
user_error('Error', E_USER_ERROR);
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
// Recoverable error
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
$x = function (ErrorControlChain $foo) {
};
$x(1); // Calling against type
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
// Memory exhaustion
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
ini_set('memory_limit', '10M');
$a = array();
while (1) {
$a[] = 1;
}
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
// Exceptions
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
throw new Exception("bob");
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
}
public function testExceptionSuppression()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
throw new Exception('This exception should be suppressed');
})
->thenIfErrored(function () {
echo "Done";
})
->executeInSubprocess();
$this->assertEquals('Done', $out);
}
public function testErrorControl()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
echo 'preThen,';
})
->thenIfErrored(function () {
echo 'preThenIfErrored,';
})
->thenAlways(function () {
echo 'preThenAlways,';
})
->then(function () {
user_error('An error', E_USER_ERROR);
})
->then(function () {
echo 'postThen,';
})
->thenIfErrored(function () {
echo 'postThenIfErrored,';
})
->thenAlways(function () {
echo 'postThenAlways,';
})
->executeInSubprocess();
$this->assertEquals(
"preThen,preThenAlways,postThenIfErrored,postThenAlways,",
$out
);
}
public function testSuppressionControl()
{
// Turning off suppression before execution
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
$chain->setSuppression(false);
list($out, $code) = $chain
->then(function ($chain) {
Foo::bar(); // Non-existant class causes fatal error
})
->executeInSubprocess(true);
$this->assertContains('Fatal error', $out);
$this->assertContains('Foo', $out);
// Turning off suppression during execution
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function ($chain) {
$chain->setSuppression(false);
Foo::bar(); // Non-existent class causes fatal error
})
->executeInSubprocess(true);
$this->assertContains('Fatal error', $out);
$this->assertContains('Foo', $out);
}
public function testDoesntAffectNonFatalErrors()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
$array = null;
if (@$array['key'] !== null) {
user_error('Error', E_USER_ERROR);
}
})
->then(function () {
echo "Good";
})
->thenIfErrored(function () {
echo "Bad";
})
->executeInSubprocess();
$this->assertContains("Good", $out);
}
public function testDoesntAffectCaughtExceptions()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
try {
throw new Exception('Error');
} catch (Exception $e) {
echo "Good";
}
})
->thenIfErrored(function () {
echo "Bad";
})
->executeInSubprocess();
$this->assertContains("Good", $out);
}
public function testDoesntAffectHandledErrors()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
list($out, $code) = $chain
->then(function () {
set_error_handler(
function () {
/* NOP */
}
);
user_error('Error', E_USER_ERROR);
})
->then(function () {
echo "Good";
})
->thenIfErrored(function () {
echo "Bad";
})
->executeInSubprocess();
$this->assertContains("Good", $out);
}
public function testMemoryConversion()
{
$chain = new ErrorControlChainTest\ErrorControlChainTest_Chain();
$this->assertEquals(200, $chain->translateMemstring('200'));
$this->assertEquals(300, $chain->translateMemstring('300'));
$this->assertEquals(2 * 1024, $chain->translateMemstring('2k'));
$this->assertEquals(3 * 1024, $chain->translateMemstring('3K'));
$this->assertEquals(2 * 1024 * 1024, $chain->translateMemstring('2m'));
$this->assertEquals(3 * 1024 * 1024, $chain->translateMemstring('3M'));
$this->assertEquals(2 * 1024 * 1024 * 1024, $chain->translateMemstring('2g'));
$this->assertEquals(3 * 1024 * 1024 * 1024, $chain->translateMemstring('3G'));
$this->assertEquals(200, $chain->translateMemstring('200foo'));
$this->assertEquals(300, $chain->translateMemstring('300foo'));
}
}

View File

@ -1,96 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\ErrorControlChainTest;
use ReflectionFunction;
use SilverStripe\Core\Manifest\ClassLoader;
use SilverStripe\Core\Startup\ErrorControlChain;
/**
* An extension of ErrorControlChain that runs the chain in a subprocess.
*
* We need this because ErrorControlChain only suppresses uncaught fatal errors, and
* that would kill PHPUnit execution
*/
class ErrorControlChainTest_Chain extends ErrorControlChain
{
protected $displayErrors = 'STDERR';
/**
* Modify method visibility to public for testing
*
* @return string
*/
public function getDisplayErrors()
{
// Protect manipulation of underlying php_ini values
return $this->displayErrors;
}
/**
* Modify method visibility to public for testing
*
* @param mixed $errors
*/
public function setDisplayErrors($errors)
{
// Protect manipulation of underlying php_ini values
$this->displayErrors = $errors;
}
// Change function visibility to be testable directly
public function translateMemstring($memstring)
{
return parent::translateMemstring($memstring);
}
function executeInSubprocess($includeStderr = false)
{
// Get the path to the ErrorControlChain class
$erroControlClass = 'SilverStripe\\Core\\Startup\\ErrorControlChain';
$classpath = ClassLoader::inst()->getItemPath($erroControlClass);
$suppression = $this->suppression ? 'true' : 'false';
// Start building a PHP file that will execute the chain
$src = '<' . "?php
require_once '$classpath';
\$chain = new $erroControlClass();
\$chain->setSuppression($suppression);
\$chain
";
// For each step, use reflection to pull out the call, stick in the the PHP source we're building
foreach ($this->steps as $step) {
$func = new ReflectionFunction($step['callback']);
$source = file($func->getFileName());
$start_line = $func->getStartLine() - 1;
$end_line = $func->getEndLine();
$length = $end_line - $start_line;
$src .= implode("", array_slice($source, $start_line, $length)) . "\n";
}
// Finally add a line to execute the chain
$src .= "->execute();";
// Now stick it in a temporary file & run it
$codepath = TEMP_PATH . DIRECTORY_SEPARATOR . 'ErrorControlChainTest_' . sha1($src) . '.php';
if ($includeStderr) {
$null = '&1';
} else {
$null = is_writeable('/dev/null') ? '/dev/null' : 'NUL';
}
file_put_contents($codepath, $src);
exec("php $codepath 2>$null", $stdout, $errcode);
unlink($codepath);
return array(implode("\n", $stdout), $errcode);
}
}

View File

@ -1,170 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Core\Startup\ParameterConfirmationToken;
use SilverStripe\Core\Tests\Startup\ParameterConfirmationTokenTest\ParameterConfirmationTokenTest_Token;
use SilverStripe\Core\Tests\Startup\ParameterConfirmationTokenTest\ParameterConfirmationTokenTest_ValidToken;
use SilverStripe\Dev\SapphireTest;
class ParameterConfirmationTokenTest extends SapphireTest
{
/**
* @var HTTPRequest
*/
protected $request = null;
protected function setUp()
{
parent::setUp();
$_GET = [];
$_GET['parameterconfirmationtokentest_notoken'] = 'value';
$_GET['parameterconfirmationtokentest_empty'] = '';
$_GET['parameterconfirmationtokentest_withtoken'] = '1';
$_GET['parameterconfirmationtokentest_withtokentoken'] = 'dummy';
$_GET['parameterconfirmationtokentest_nulltoken'] = '1';
$_GET['parameterconfirmationtokentest_nulltokentoken'] = null;
$_GET['parameterconfirmationtokentest_emptytoken'] = '1';
$_GET['parameterconfirmationtokentest_emptytokentoken'] = '';
$_GET['BackURL'] = 'page?parameterconfirmationtokentest_backtoken=1';
$this->request = new HTTPRequest('GET', 'anotherpage', $_GET);
$this->request->setSession(new Session([]));
}
public function testParameterDetectsParameters()
{
$withoutToken = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_notoken', $this->request);
$emptyParameter = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_empty', $this->request);
$withToken = new ParameterConfirmationTokenTest_ValidToken('parameterconfirmationtokentest_withtoken', $this->request);
$withoutParameter = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_noparam', $this->request);
$nullToken = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_nulltoken', $this->request);
$emptyToken = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_emptytoken', $this->request);
$backToken = new ParameterConfirmationTokenTest_Token('parameterconfirmationtokentest_backtoken', $this->request);
// Check parameter
$this->assertTrue($withoutToken->parameterProvided());
$this->assertTrue($emptyParameter->parameterProvided()); // even if empty, it's still provided
$this->assertTrue($withToken->parameterProvided());
$this->assertFalse($withoutParameter->parameterProvided());
$this->assertTrue($nullToken->parameterProvided());
$this->assertTrue($emptyToken->parameterProvided());
$this->assertFalse($backToken->parameterProvided());
// Check backurl
$this->assertFalse($withoutToken->existsInReferer());
$this->assertFalse($emptyParameter->existsInReferer()); // even if empty, it's still provided
$this->assertFalse($withToken->existsInReferer());
$this->assertFalse($withoutParameter->existsInReferer());
$this->assertFalse($nullToken->existsInReferer());
$this->assertFalse($emptyToken->existsInReferer());
$this->assertTrue($backToken->existsInReferer());
// Check token
$this->assertFalse($withoutToken->tokenProvided());
$this->assertFalse($emptyParameter->tokenProvided());
$this->assertTrue($withToken->tokenProvided()); // Actually forced to true for this test
$this->assertFalse($withoutParameter->tokenProvided());
$this->assertFalse($nullToken->tokenProvided());
$this->assertFalse($emptyToken->tokenProvided());
$this->assertFalse($backToken->tokenProvided());
// Check if reload is required
$this->assertTrue($withoutToken->reloadRequired());
$this->assertTrue($emptyParameter->reloadRequired());
$this->assertFalse($withToken->reloadRequired());
$this->assertFalse($withoutParameter->reloadRequired());
$this->assertTrue($nullToken->reloadRequired());
$this->assertTrue($emptyToken->reloadRequired());
$this->assertFalse($backToken->reloadRequired());
// Check if a reload is required in case of error
$this->assertTrue($withoutToken->reloadRequiredIfError());
$this->assertTrue($emptyParameter->reloadRequiredIfError());
$this->assertFalse($withToken->reloadRequiredIfError());
$this->assertFalse($withoutParameter->reloadRequiredIfError());
$this->assertTrue($nullToken->reloadRequiredIfError());
$this->assertTrue($emptyToken->reloadRequiredIfError());
$this->assertTrue($backToken->reloadRequiredIfError());
// Check redirect url
$home = (BASE_URL ?: '/') . '?';
$current = Controller::join_links(BASE_URL, '/', 'anotherpage') . '?';
$this->assertStringStartsWith($current, $withoutToken->redirectURL());
$this->assertStringStartsWith($current, $emptyParameter->redirectURL());
$this->assertStringStartsWith($current, $nullToken->redirectURL());
$this->assertStringStartsWith($current, $emptyToken->redirectURL());
$this->assertStringStartsWith($home, $backToken->redirectURL());
// Check suppression
$this->assertEquals('value', $this->request->getVar('parameterconfirmationtokentest_notoken'));
$withoutToken->suppress();
$this->assertNull($this->request->getVar('parameterconfirmationtokentest_notoken'));
}
public function testPrepareTokens()
{
// Test priority ordering
$token = ParameterConfirmationToken::prepare_tokens(
[
'parameterconfirmationtokentest_notoken',
'parameterconfirmationtokentest_empty',
'parameterconfirmationtokentest_noparam'
],
$this->request
);
// Test no invalid tokens
$this->assertEquals('parameterconfirmationtokentest_empty', $token->getName());
$token = ParameterConfirmationToken::prepare_tokens(
[ 'parameterconfirmationtokentest_noparam' ],
$this->request
);
$this->assertEmpty($token);
// Test backurl token
$token = ParameterConfirmationToken::prepare_tokens(
[ 'parameterconfirmationtokentest_backtoken' ],
$this->request
);
$this->assertEquals('parameterconfirmationtokentest_backtoken', $token->getName());
// Test prepare_tokens() unsets $_GET vars
$this->assertArrayNotHasKey('parameterconfirmationtokentest_notoken', $_GET);
$this->assertArrayNotHasKey('parameterconfirmationtokentest_empty', $_GET);
$this->assertArrayNotHasKey('parameterconfirmationtokentest_noparam', $_GET);
}
public function dataProviderURLs()
{
return [
[''],
['/'],
['bar'],
['bar/'],
['/bar'],
['/bar/'],
];
}
/**
* currentURL needs to handle base or url being missing, or any combination of slashes.
*
* There should always be exactly one slash between each part in the result, and any trailing slash
* should be preserved.
*
* @dataProvider dataProviderURLs
*/
public function testCurrentURLHandlesSlashes($url)
{
$this->request->setUrl($url);
$token = new ParameterConfirmationTokenTest_Token(
'parameterconfirmationtokentest_parameter',
$this->request
);
$expected = rtrim(Controller::join_links(BASE_URL, '/', $url), '/') ?: '/';
$this->assertEquals($expected, $token->currentURL(), "Invalid redirect for request url $url");
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\ParameterConfirmationTokenTest;
use SilverStripe\Core\Startup\ParameterConfirmationToken;
use SilverStripe\Dev\TestOnly;
/**
* Dummy parameter token
*/
class ParameterConfirmationTokenTest_Token extends ParameterConfirmationToken implements TestOnly
{
public function currentURL()
{
return parent::currentURL();
}
public function redirectURL()
{
return parent::redirectURL();
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\ParameterConfirmationTokenTest;
/**
* A token that always validates a given token
*/
class ParameterConfirmationTokenTest_ValidToken extends ParameterConfirmationTokenTest_Token
{
protected function checkToken($token)
{
return true;
}
}

View File

@ -1,148 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Core\Startup\URLConfirmationToken;
use SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest\StubToken;
use SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest\StubValidToken;
use SilverStripe\Dev\SapphireTest;
class URLConfirmationTokenTest extends SapphireTest
{
public function testValidToken()
{
$request = new HTTPRequest('GET', 'token/test/url', ['tokentesturltoken' => 'value']);
$validToken = new StubValidToken('token/test/url', $request);
$this->assertTrue($validToken->urlMatches());
$this->assertFalse($validToken->urlExistsInBackURL());
$this->assertTrue($validToken->tokenProvided()); // Actually forced to true for this test
$this->assertFalse($validToken->reloadRequired());
$this->assertFalse($validToken->reloadRequiredIfError());
$this->assertStringStartsWith(Controller::join_links(BASE_URL, '/', 'token/test/url'), $validToken->redirectURL());
}
public function testTokenWithLeadingSlashInUrl()
{
$request = new HTTPRequest('GET', '/leading/slash/url', []);
$leadingSlash = new StubToken('leading/slash/url', $request);
$this->assertTrue($leadingSlash->urlMatches());
$this->assertFalse($leadingSlash->urlExistsInBackURL());
$this->assertFalse($leadingSlash->tokenProvided());
$this->assertTrue($leadingSlash->reloadRequired());
$this->assertTrue($leadingSlash->reloadRequiredIfError());
$this->assertContains('leading/slash/url', $leadingSlash->redirectURL());
$this->assertContains('leadingslashurltoken', $leadingSlash->redirectURL());
}
public function testTokenWithTrailingSlashInUrl()
{
$request = new HTTPRequest('GET', 'trailing/slash/url/', []);
$trailingSlash = new StubToken('trailing/slash/url', $request);
$this->assertTrue($trailingSlash->urlMatches());
$this->assertFalse($trailingSlash->urlExistsInBackURL());
$this->assertFalse($trailingSlash->tokenProvided());
$this->assertTrue($trailingSlash->reloadRequired());
$this->assertTrue($trailingSlash->reloadRequiredIfError());
$this->assertContains('trailing/slash/url', $trailingSlash->redirectURL());
$this->assertContains('trailingslashurltoken', $trailingSlash->redirectURL());
}
public function testTokenWithUrlMatchedInBackUrl()
{
$request = new HTTPRequest('GET', '/', ['BackURL' => 'back/url']);
$backUrl = new StubToken('back/url', $request);
$this->assertFalse($backUrl->urlMatches());
$this->assertTrue($backUrl->urlExistsInBackURL());
$this->assertFalse($backUrl->tokenProvided());
$this->assertFalse($backUrl->reloadRequired());
$this->assertTrue($backUrl->reloadRequiredIfError());
$home = (BASE_URL ?: '/') . '?';
$this->assertStringStartsWith($home, $backUrl->redirectURL());
$this->assertContains('backurltoken', $backUrl->redirectURL());
}
public function testUrlSuppressionWhenTokenMissing()
{
// Check suppression
$request = new HTTPRequest('GET', 'test/url', []);
$token = new StubToken('test/url', $request);
$this->assertEquals('test/url', $request->getURL(false));
$token->suppress();
$this->assertEquals('', $request->getURL(false));
}
public function testPrepareTokens()
{
$request = new HTTPRequest('GET', 'test/url', []);
$token = URLConfirmationToken::prepare_tokens(
[
'test/url',
'test',
'url'
],
$request
);
// Test no invalid tokens
$this->assertEquals('test/url', $token->getURLToCheck());
$this->assertNotEquals('test/url', $request->getURL(false), 'prepare_tokens() did not suppress URL');
}
public function testPrepareTokensDoesntSuppressWhenNotMatched()
{
$request = new HTTPRequest('GET', 'test/url', []);
$token = URLConfirmationToken::prepare_tokens(
['another/url'],
$request
);
$this->assertEmpty($token);
$this->assertEquals('test/url', $request->getURL(false), 'prepare_tokens() incorrectly suppressed URL');
}
public function testPrepareTokensWithUrlMatchedInBackUrl()
{
// Test backurl token
$request = new HTTPRequest('GET', '/', ['BackURL' => 'back/url']);
$token = URLConfirmationToken::prepare_tokens(
[ 'back/url' ],
$request
);
$this->assertNotEmpty($token);
$this->assertEquals('back/url', $token->getURLToCheck());
$this->assertNotEquals('back/url', $request->getURL(false), 'prepare_tokens() did not suppress URL');
}
public function dataProviderURLs()
{
return [
[''],
['/'],
['bar'],
['bar/'],
['/bar'],
['/bar/'],
];
}
/**
* currentURL needs to handle base or url being missing, or any combination of slashes.
*
* There should always be exactly one slash between each part in the result, and any trailing slash
* should be preserved.
*
* @dataProvider dataProviderURLs
*/
public function testCurrentURLHandlesSlashes($url)
{
$request = new HTTPRequest('GET', $url, []);
$token = new StubToken(
'another/url',
$request
);
$expected = rtrim(Controller::join_links(BASE_URL, '/', $url), '/') ?: '/';
$this->assertEquals($expected, $token->currentURL(), "Invalid redirect for request url $url");
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest;
use SilverStripe\Core\Startup\URLConfirmationToken;
use SilverStripe\Dev\TestOnly;
/**
* Dummy url token
*/
class StubToken extends URLConfirmationToken implements TestOnly
{
public function urlMatches()
{
return parent::urlMatches();
}
public function currentURL()
{
return parent::currentURL();
}
public function redirectURL()
{
return parent::redirectURL();
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest;
/**
* A token that always validates a given token
*/
class StubValidToken extends StubToken
{
protected function checkToken($token)
{
return true;
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace SilverStripe\Security\Tests\Confirmation;
use SilverStripe\Control\Session;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Confirmation\Storage;
use SilverStripe\Security\Confirmation\Item;
use SilverStripe\Control\Tests\HttpRequestMockBuilder;
class StorageTest extends SapphireTest
{
use HttpRequestMockBuilder;
private function getNamespace($id)
{
return str_replace('\\', '.', Storage::class).'.'.$id;
}
public function testNewStorage()
{
$session = $this->createMock(Session::class);
$sessionCleaned = false;
$session->method('clear')->will($this->returnCallback(static function ($namespace) use (&$sessionCleaned) {
$sessionCleaned = $namespace;
}));
$storage = new Storage($session, 'test');
$this->assertEquals(
$this->getNamespace('test'),
$sessionCleaned,
'Session data should have been cleaned from the obsolete data'
);
$sessionCleaned = false;
$storage = new Storage($session, 'test', false);
$this->assertFalse($sessionCleaned, 'Session data should have been preserved');
}
public function testCleanup()
{
$session = $this->createMock(Session::class);
$sessionCleaned = false;
$session->method('clear')->will($this->returnCallback(static function ($namespace) use (&$sessionCleaned) {
$sessionCleaned = $namespace;
}));
$storage = new Storage($session, 'test', false);
$this->assertFalse($sessionCleaned, 'Session data should have been preserved');
$storage->cleanup();
$this->assertEquals(
$this->getNamespace('test'),
$sessionCleaned,
'Session data should have been cleaned up'
);
}
public function testSuccessRequest()
{
$session = new Session([]);
$storage = new Storage($session, 'test');
$request = $this->buildRequestMock('dev/build', ['flush' => 'all']);
$storage->setSuccessRequest($request);
// ensure the data is persisted within the session
$storage = new Storage($session, 'test', false);
$this->assertEquals('dev/build?flush=all', $storage->getSuccessUrl());
$this->assertEquals('GET', $storage->getHttpMethod());
}
public function testPutItem()
{
$session = new Session([]);
$storage = new Storage($session, 'test');
$item1 = new Item('item1_token', 'item1_name', 'item1_desc');
$item2 = new Item('item2_token', 'item2_name', 'item2_desc');
$storage->putItem($item1);
$storage->putItem($item2);
// ensure the data is persisted within the session
$storage = new Storage($session, 'test', false);
$items = $storage->getItems();
$hashedItems = $storage->getHashedItems();
$this->assertCount(2, $items);
$this->assertCount(2, $hashedItems);
$item1Hash = $storage->getTokenHash($item1);
$this->assertArrayHasKey($item1Hash, $items);
$item = $items[$item1Hash];
$this->assertEquals('item1_token', $item->getToken());
$this->assertEquals('item1_name', $item->getName());
$this->assertEquals('item1_desc', $item->getDescription());
$this->assertFalse($item->isConfirmed());
$item2Hash = $storage->getTokenHash($item2);
$this->assertArrayHasKey($item2Hash, $items);
$item = $items[$item2Hash];
$this->assertEquals('item2_token', $item->getToken());
$this->assertEquals('item2_name', $item->getName());
$this->assertEquals('item2_desc', $item->getDescription());
$this->assertFalse($item->isConfirmed());
}
public function testConfirmation()
{
$session = new Session([]);
$storage = new Storage($session, 'test');
$item1 = new Item('item1_token', 'item1_name', 'item1_desc');
$item2 = new Item('item2_token', 'item2_name', 'item2_desc');
$storage->putItem($item1);
$storage->putItem($item2);
// ensure the data is persisted within the session
$storage = new Storage($session, 'test', false);
foreach ($storage->getItems() as $item) {
$this->assertFalse($item->isConfirmed());
}
$this->assertFalse($storage->check([$item1, $item2]));
// check we cannot confirm items with incorrect data
$storage->confirm([]);
foreach ($storage->getItems() as $item) {
$this->assertFalse($item->isConfirmed());
}
$this->assertFalse($storage->check([$item1, $item2]));
// check we cannot confirm items with unsalted tokens
$storage->confirm(['item1_token' => '1', 'item2_token' => '1']);
foreach ($storage->getItems() as $item) {
$this->assertFalse($item->isConfirmed());
}
$this->assertFalse($storage->check([$item1, $item2]));
// check we can confirm data with properly salted tokens
$storage->confirm($storage->getHashedItems());
foreach ($storage->getItems() as $item) {
$this->assertTrue($item->isConfirmed());
}
$this->assertTrue($storage->check([$item1, $item2]));
}
}

View File

@ -3,6 +3,7 @@
namespace SilverStripe\View\Tests;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\TempFolder;
use SilverStripe\Versioned\Versioned;
use Psr\SimpleCache\CacheInterface;
@ -153,6 +154,7 @@ class SSViewerCacheBlockTest extends SapphireTest
$this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 3)), '1');
// Test with flush
Injector::inst()->get(Kernel::class)->boot(true);
Director::test('/?flush=1');
$this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 2)), '2');
}