diff --git a/_config/injector.yml b/_config/injector.yml index 2b35577..b92a2ab 100644 --- a/_config/injector.yml +++ b/_config/injector.yml @@ -22,3 +22,14 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\EnvironmentCheck\Checks\SolrIndexCheck URLCheck: class: SilverStripe\EnvironmentCheck\Checks\URLCheck + EnvCheckClient: + factory: 'SilverStripe\EnvironmentCheck\Services\ClientFactory' + constructor: + timeout: 10.0 + +SilverStripe\EnvironmentCheck\Checks\SessionCheck: + dependencies: + client: '%$EnvCheckClient' +SilverStripe\EnvironmentCheck\Checks\CacheHeadersCheck: + dependencies: + client: '%$EnvCheckClient' diff --git a/composer.json b/composer.json index 923f988..724a229 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/readme.md b/readme.md index 2415114..f61aeae 100644 --- a/readme.md +++ b/readme.md @@ -92,6 +92,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. * `EnvTypeCheck`: Checks environment type, dev and test should not be used on production environments. ## Monitoring Checks diff --git a/src/Checks/CacheHeadersCheck.php b/src/Checks/CacheHeadersCheck.php new file mode 100644 index 0000000..f42f174 --- /dev/null +++ b/src/Checks/CacheHeadersCheck.php @@ -0,0 +1,207 @@ +setURL($url); + $this->mustInclude = $mustInclude; + $this->mustExclude = $mustExclude; + } + + /** + * Check that correct caching headers are present. + * + * @return void + */ + public function check() + { + // Using a validation result to capture messages + $this->result = new ValidationResult(); + + $response = $this->client->get($this->getURL()); + $fullURL = $this->getURL(); + 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}" + ); + } + } +} diff --git a/src/Checks/SessionCheck.php b/src/Checks/SessionCheck.php new file mode 100644 index 0000000..47a95bd --- /dev/null +++ b/src/Checks/SessionCheck.php @@ -0,0 +1,71 @@ +setURL($url); + } + + /** + * Check that the response for URL does not create a session + * + * @return array + */ + public function check() + { + $response = $this->client->get($this->getURL()); + $cookie = $this->getCookie($response); + $fullURL = $this->getURL(); + + 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; + } +} diff --git a/src/Services/ClientFactory.php b/src/Services/ClientFactory.php new file mode 100644 index 0000000..ef066f0 --- /dev/null +++ b/src/Services/ClientFactory.php @@ -0,0 +1,49 @@ +getConfig($params)); + } + + /** + * Merge config provided from yaml with default config + * + * @param array $overrides + * @return array + */ + public function getConfig(array $overrides) + { + return array_merge( + $this->config()->get('default_config'), + $overrides + ); + } +} diff --git a/src/Traits/Fetcher.php b/src/Traits/Fetcher.php new file mode 100644 index 0000000..60dd5c3 --- /dev/null +++ b/src/Traits/Fetcher.php @@ -0,0 +1,51 @@ +url = Director::absoluteURL($url); + return $this; + } + + /** + * Getter for URL + * + * @return string + */ + public function getURL() + { + return $this->url; + } +} diff --git a/tests/Checks/CacheHeadersCheckTest.php b/tests/Checks/CacheHeadersCheckTest.php new file mode 100644 index 0000000..9fe0a7a --- /dev/null +++ b/tests/Checks/CacheHeadersCheckTest.php @@ -0,0 +1,101 @@ + 'must-revalidate', 'ETag' => '123']), + new Response(200, ['Cache-Control' =>'no-cache', 'ETag' => '123']), + new Response(200, ['ETag' => '123']), + new Response(200, ['Cache-Control' => 'must-revalidate, private', 'ETag' => '123']), + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $cacheHeadersCheck = new CacheHeadersCheck('/', ['must-revalidate']); + $cacheHeadersCheck->client = $client; + + // Run checks for each response above + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + } + + /** + * Test that directives that must be excluded, are. + * + * @return void + */ + public function testMustExclude() + { + // Create a mock and queue responses + $mock = new MockHandler([ + new Response(200, ['Cache-Control' => 'must-revalidate', 'ETag' => '123']), + new Response(200, ['Cache-Control' =>'no-cache', 'ETag' => '123']), + new Response(200, ['ETag' => '123']), + new Response(200, ['Cache-Control' =>'private, no-store', ' ETag' => '123']), + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $cacheHeadersCheck = new CacheHeadersCheck('/', [], ["no-store", "no-cache", "private"]); + $cacheHeadersCheck->client = $client; + + // Run checks for each response above + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + } + + /** + * Test that Etag header must exist in response. + * + * @return void + */ + public function testEtag() + { + // Create a mock and queue responses + $mock = new MockHandler([ + new Response(200, ['Cache-Control' => 'must-revalidate', 'ETag' => '123']), + new Response(200, ['Cache-Control' =>'no-cache']), + new Response(200, ['ETag' => '123']), + new Response(200, []), + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $cacheHeadersCheck = new CacheHeadersCheck('/'); + $cacheHeadersCheck->client = $client; + + // Run checks for each response above + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::OK, $cacheHeadersCheck->check()); + $this->assertContains(EnvironmentCheck::ERROR, $cacheHeadersCheck->check()); + } +} diff --git a/tests/Checks/SessionCheckTest.php b/tests/Checks/SessionCheckTest.php new file mode 100644 index 0000000..a84bcef --- /dev/null +++ b/tests/Checks/SessionCheckTest.php @@ -0,0 +1,76 @@ +sessionCheck = new SessionCheck('/'); + } + + /** + * Env check reports error when session cookies are being set. + * + * @return void + */ + public function testSessionSet() + { + // Create a mock and queue two responses. + $mock = new MockHandler([ + new Response(200, ['Set-Cookie' => 'PHPSESSID:foo']), + new Response(200, ['Set-Cookie' => 'SECSESSID:bar']) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->sessionCheck->client = $client; + + // Check for PHPSESSID + $this->assertContains(EnvironmentCheck::ERROR, $this->sessionCheck->check()); + + // Check for SECSESSID + $this->assertContains(EnvironmentCheck::ERROR, $this->sessionCheck->check()); + } + + /** + * Env check responds OK when no session cookies are set in response. + * + * @return void + */ + public function testSessionNotSet() + { + // Create a mock and queue two responses. + $mock = new MockHandler([ + new Response(200) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->sessionCheck->client = $client; + + $this->assertContains(EnvironmentCheck::OK, $this->sessionCheck->check()); + } +}