Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extra checks #60

Merged
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -16,7 +16,8 @@
],
"require": {
"silverstripe/framework": "^4.0",
"silverstripe/versioned": "^1.0"
"silverstripe/versioned": "^1.0",
"guzzlehttp/guzzle": "^6.3.3"
},
"require-dev": {
"phpunit/phpunit": "^5.7",
Expand Down
3 changes: 3 additions & 0 deletions readme.md
Expand Up @@ -91,6 +91,9 @@ SilverStripe\EnvironmentCheck\EnvironmentCheckSuite:
* `ExternalURLCheck`: Checks that one or more URLs are reachable via HTTP.
* `SMTPConnectCheck`: Checks if the SMTP connection configured through PHP.ini works as expected.
* `SolrIndexCheck`: Checks if the Solr cores of given class are available.
* `SessionCheck`: Checks that a given URL does not generate a session.
* `CacheHeadersCheck`: Check cache headers in response for directives that must either be included or excluded as well
checking for existence of ETag.

## Monitoring Checks

Expand Down
220 changes: 220 additions & 0 deletions src/Checks/CacheHeadersCheck.php
@@ -0,0 +1,220 @@
<?php

namespace SilverStripe\EnvironmentCheck\Checks;

use SilverStripe\Control\Director;
use SilverStripe\Control\Controller;
use SilverStripe\ORM\ValidationResult;
use Psr\Http\Message\ResponseInterface;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\EnvironmentCheck\Traits\Fetcher;
use SilverStripe\EnvironmentCheck\EnvironmentCheck;

/**
* Check cache headers for any response, can specify directives that must be included and
* also must be excluded from Cache-Control headers in response. Also checks for
* existence of ETag.
*
* @example SilverStripe\EnvironmentCheck\Checks\CacheHeadersCheck("/",["must-revalidate", "max-age=120"],["no-store"])
* @package environmentcheck
*/
class CacheHeadersCheck implements EnvironmentCheck
{
use Configurable;
use Fetcher;

/**
* URL to check
*
* @var string
*/
protected $url;

/**
* Settings that must be included in the Cache-Control header
*
* @var array
*/
protected $mustInclude = [];

/**
* Settings that must be excluded in the Cache-Control header
*
* @var array
*/
protected $mustExclude = [];

/**
* Result to keep track of status and messages for all checks, reuses
* ValidationResult for convenience.
*
* @var ValidationResult
*/
protected $result;

/**
* Set up with URL, arrays of header settings to check.
*
* @param string $url
* @param array $mustInclude Settings that must be included in Cache-Control
* @param array $mustExclude Settings that must be excluded in Cache-Control
*/
public function __construct($url = '', $mustInclude = [], $mustExclude = [])
{
$this->url = $url;
$this->mustInclude = $mustInclude;
$this->mustExclude = $mustExclude;

$this->clientConfig = [
'base_uri' => Director::absoluteBaseURL(),
'timeout' => 10.0,
];

// Using a validation result to capture messages
$this->result = new ValidationResult();
}

/**
* Check that correct caching headers are present.
*
* @return void
*/
public function check()
{
$response = $this->fetchResponse($this->url);
$fullURL = Controller::join_links(Director::absoluteBaseURL(), $this->url);
if ($response === null) {
return [
EnvironmentCheck::ERROR,
"Cache headers check request failed for $fullURL",
];
}

//Check that Etag exists
$this->checkEtag($response);

// Check Cache-Control settings
$this->checkCacheControl($response);

if ($this->result->isValid()) {
return [
EnvironmentCheck::OK,
$this->getMessage(),
];
} else {
// @todo Ability to return a warning
return [
EnvironmentCheck::ERROR,
$this->getMessage(),
];
}
}

/**
* Collate messages from ValidationResult so that it is clear which parts
* of the check passed and which failed.
*
* @return string
*/
private function getMessage()
{
$ret = '';
// Filter good messages
$goodTypes = [ValidationResult::TYPE_GOOD, ValidationResult::TYPE_INFO];
$good = array_filter(
$this->result->getMessages(),
function ($val, $key) use ($goodTypes) {
if (in_array($val['messageType'], $goodTypes)) {
return true;
}
return false;
},
ARRAY_FILTER_USE_BOTH
);
if (!empty($good)) {
$ret .= "GOOD: " . implode('; ', array_column($good, 'message')) . " ";
}

// Filter bad messages
$badTypes = [ValidationResult::TYPE_ERROR, ValidationResult::TYPE_WARNING];
$bad = array_filter(
$this->result->getMessages(),
function ($val, $key) use ($badTypes) {
if (in_array($val['messageType'], $badTypes)) {
return true;
}
return false;
},
ARRAY_FILTER_USE_BOTH
);
if (!empty($bad)) {
$ret .= "BAD: " . implode('; ', array_column($bad, 'message'));
}
return $ret;
}

/**
* Check that ETag header exists
*
* @param ResponseInterface $response
* @return void
*/
private function checkEtag(ResponseInterface $response)
{
$eTag = $response->getHeaderLine('ETag');
$fullURL = Controller::join_links(Director::absoluteBaseURL(), $this->url);

if ($eTag) {
$this->result->addMessage(
"$fullURL includes an Etag header in response",
ValidationResult::TYPE_GOOD
);
return;
}
$this->result->addError(
"$fullURL is missing an Etag header",
ValidationResult::TYPE_WARNING
);
}

/**
* Check that the correct header settings are either included or excluded.
*
* @param ResponseInterface $response
* @return void
*/
private function checkCacheControl(ResponseInterface $response)
{
$cacheControl = $response->getHeaderLine('Cache-Control');
$vals = array_map('trim', explode(',', $cacheControl));
$fullURL = Controller::join_links(Director::absoluteBaseURL(), $this->url);

// All entries from must contain should be present
if ($this->mustInclude == array_intersect($this->mustInclude, $vals)) {
$matched = implode(",", $this->mustInclude);
$this->result->addMessage(
"$fullURL includes all settings: {$matched}",
ValidationResult::TYPE_GOOD
);
} else {
$missing = implode(",", array_diff($this->mustInclude, $vals));
$this->result->addError(
"$fullURL is excluding some settings: {$missing}"
);
}

// All entries from must exclude should not be present
if (empty(array_intersect($this->mustExclude, $vals))) {
$missing = implode(",", $this->mustExclude);
$this->result->addMessage(
"$fullURL excludes all settings: {$missing}",
ValidationResult::TYPE_GOOD
);
} else {
$matched = implode(",", array_intersect($this->mustExclude, $vals));
$this->result->addError(
"$fullURL is including some settings: {$matched}"
);
}
}
}
86 changes: 86 additions & 0 deletions src/Checks/SessionCheck.php
@@ -0,0 +1,86 @@
<?php

namespace SilverStripe\EnvironmentCheck\Checks;

use SilverStripe\Control\Director;
use SilverStripe\Control\Controller;
use Psr\Http\Message\ResponseInterface;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\EnvironmentCheck\Traits\Fetcher;
use SilverStripe\EnvironmentCheck\EnvironmentCheck;

/**
* Check that a given URL does not generate a session.
*
* @author Adrian Humphreys
* @package environmentcheck
*/
class SessionCheck implements EnvironmentCheck
{
use Configurable;
use Fetcher;

/**
* URL to check
*
* @var string
*/
protected $url;

/**
* Set up check with URL
*
* @param string $url The route, excluding the domain
* @inheritdoc
*/
public function __construct($url = '')
{
$this->url = $url;
$this->clientConfig = [
'base_uri' => Director::absoluteBaseURL(),
'timeout' => 10.0,
];
}

/**
* Check that the response for URL does not create a session
*
* @return array
*/
public function check()
{
$response = $this->fetchResponse($this->url);
$cookie = $this->getCookie($response);
$fullURL = Controller::join_links(Director::absoluteBaseURL(), $this->url);

if ($cookie) {
return [
EnvironmentCheck::ERROR,
"Sessions are being set for {$fullURL} : Set-Cookie => " . $cookie,
];
}
return [
EnvironmentCheck::OK,
"Sessions are not being created for {$fullURL} 👍",
];
}

/**
* Get PHPSESSID or SECSESSID cookie set from the response if it exists.
*
* @param ResponseInterface $response
* @return string|null Cookie contents or null if it doesn't exist
*/
public function getCookie(ResponseInterface $response)
{
$result = null;
$cookies = $response->getHeader('Set-Cookie');

foreach ($cookies as $cookie) {
if (strpos($cookie, 'SESSID') !== false) {
$result = $cookie;
}
}
return $result;
}
}
46 changes: 46 additions & 0 deletions src/Traits/Fetcher.php
@@ -0,0 +1,46 @@
<?php

namespace SilverStripe\EnvironmentCheck\Traits;

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

/**
* Simple helper for fetching responses using Guzzle client.
*
* @package environmentcheck
*/
trait Fetcher
{
/**
* Configuration for the Guzzle client
*
* @var array
*/
protected $clientConfig = [];

/**
* Merges configuration arrays and returns the result
*
* @param array $extraConfig
* @return array
*/
private function getClientConfig(array $extraConfig = [])
{
return array_merge($this->clientConfig, $extraConfig);
}

/**
* Fetch a response for a URL using Guzzle client.
*
* @param string $url
* @param array|null $extraConfig Extra configuration
* @return ResponseInterface
*/
public function fetchResponse(string $url, array $extraConfig = [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scalar typehints aren't PHP 5.6 friendly =)

{
$config = $this->getClientConfig($extraConfig);
$client = new Client($config);
return $client->get($url);
}
}