From 2b4954035f950beef9be8ba8e36a2b620d6aa332 Mon Sep 17 00:00:00 2001 From: Daniel Hensby Date: Fri, 8 Jun 2018 00:56:31 +0100 Subject: [PATCH] NEW Add better HTTP cache-control manipulation (#8086) --- _config/config.yml | 12 +- api/RSSFeed.php | 5 +- control/Controller.php | 5 +- control/Director.php | 9 +- control/FlushRequestFilter.php | 3 + control/HTTP.php | 281 ++++++----- control/HTTPCacheControl.php | 442 ++++++++++++++++++ control/HTTPResponse.php | 12 +- control/VersionedRequestFilter.php | 4 + dev/Log.php | 6 +- .../03_Forms/04_Form_Security.md | 15 +- .../08_Performance/02_HTTP_Cache_Headers.md | 209 ++++++++- .../09_Security/04_Secure_Coding.md | 9 + docs/en/04_Changelogs/3.7.0.md | 131 +++++- forms/Form.php | 4 +- main.php | 3 + security/Security.php | 2 + .../HTTPCacheControlIntegrationTest.php | 190 ++++++++ tests/control/HTTPCacheControlTest.php | 66 +++ tests/control/HTTPTest.php | 55 ++- 20 files changed, 1299 insertions(+), 164 deletions(-) create mode 100644 control/HTTPCacheControl.php create mode 100644 tests/control/HTTPCacheControlIntegrationTest.php create mode 100644 tests/control/HTTPCacheControlTest.php diff --git a/_config/config.yml b/_config/config.yml index fc792c372..424b8145b 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -13,13 +13,19 @@ MySQLDatabase: collation: utf8_general_ci HTTP: cache_control: - max-age: 0 + no-cache: "true" + no-store: "true" must-revalidate: "true" - no-transform: "true" - vary: "Cookie, X-Forwarded-Protocol, User-Agent, Accept" + vary: "X-Requested-With, X-Forwarded-Protocol" LeftAndMain: dependencies: versionProvider: %$SilverStripeVersionProvider SilverStripeVersionProvider: modules: silverstripe/framework: Framework +--- +Only: + environment: dev +--- +HTTP: + disable_http_cache: true diff --git a/api/RSSFeed.php b/api/RSSFeed.php index 8aa774320..5291fdeee 100644 --- a/api/RSSFeed.php +++ b/api/RSSFeed.php @@ -204,10 +204,7 @@ class RSSFeed extends ViewableData { HTTP::register_etag($this->etag); } - if(!headers_sent()) { - HTTP::add_cache_headers(); - $response->addHeader("Content-Type", "application/rss+xml; charset=utf-8"); - } + $response->addHeader("Content-Type", "application/rss+xml; charset=utf-8"); Config::inst()->update('SSViewer', 'source_file_comments', $prevState); diff --git a/control/Controller.php b/control/Controller.php index 1d9e4a9a7..5707aa53a 100644 --- a/control/Controller.php +++ b/control/Controller.php @@ -169,10 +169,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider { $response->setBody($body); } - ContentNegotiator::process($response); - HTTP::add_cache_headers($response); - $this->popCurrent(); return $response; } @@ -502,7 +499,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider { */ public function redirectBack() { // Don't cache the redirect back ever - HTTP::set_cache_age(0); + HTTPCacheControl::singleton()->disableCache(true); $url = null; diff --git a/control/Director.php b/control/Director.php index aef7f868f..d6ddc97ca 100644 --- a/control/Director.php +++ b/control/Director.php @@ -386,7 +386,14 @@ class Director implements TemplateGlobalProvider { } catch(SS_HTTPResponse_Exception $responseException) { $result = $responseException->getResponse(); } - if(!is_object($result) || $result instanceof SS_HTTPResponse) return $result; + // Ensure cache headers are added + if ($result instanceof SS_HTTPResponse) { + HTTP::add_cache_headers($result); + return $result; + } + if(!is_object($result)) { + return $result; + } user_error("Bad result from url " . $request->getURL() . " handled by " . get_class($controllerObj)." controller: ".get_class($result), E_USER_WARNING); diff --git a/control/FlushRequestFilter.php b/control/FlushRequestFilter.php index ab7133ffb..5a6e2953e 100644 --- a/control/FlushRequestFilter.php +++ b/control/FlushRequestFilter.php @@ -18,6 +18,9 @@ class FlushRequestFilter implements RequestFilter { } public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { + if(array_key_exists('flush', $request->getVars())) { + HTTPCacheControl::singleton()->disableCache(true); + } return true; } diff --git a/control/HTTP.php b/control/HTTP.php index 1f0d088c6..e4f0ac4d7 100644 --- a/control/HTTP.php +++ b/control/HTTP.php @@ -6,6 +6,7 @@ * * @package framework * @subpackage misc + * @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ */ class HTTP { @@ -15,7 +16,7 @@ class HTTP { protected static $cache_age = 0; /** - * @var timestamp $modification_date + * @var int $modification_date */ protected static $modification_date = null; @@ -29,15 +30,48 @@ class HTTP { */ private static $cache_ajax_requests = true; + /** + * @config + * @var bool + */ + private static $disable_http_cache = false; + + /** + * Mapping of extension to mime types + * + * @var array + * @config + */ + private static $MimeTypes = array(); + + /** + * List of names to add to the Cache-Control header. + * + * @see HTTPCacheControl::__construct() + * @config + * @var array Keys are cache control names, values are boolean flags + */ + private static $cache_control = array(); + + /** + * Vary string; A comma separated list of var header names + * + * @config + * @var string|null + */ + private static $vary = null; + /** * Turns a local system filename into a URL by comparing it to the script * filename. * * @param string + * @return string */ public static function filename2url($filename) { $slashPos = -1; + $commonLength = null; while(($slashPos = strpos($filename, "/", $slashPos+1)) !== false) { if(substr($filename, 0, $slashPos) == substr($_SERVER['SCRIPT_FILENAME'],0,$slashPos)) { $commonLength = $slashPos; @@ -63,6 +97,9 @@ class HTTP { /** * Turn all relative URLs in the content to absolute URLs + * + * @param string $html + * @return string */ public static function absoluteURLs($html) { $html = str_replace('$CurrentPageURL', $_SERVER['REQUEST_URI'], $html); @@ -99,7 +136,7 @@ class HTTP { * @param string|callable $code Either a string that can evaluate to an expression * to rewrite links (depreciated), or a callable that takes a single * parameter and returns the rewritten URL - * @return The content with all links rewritten as per the logic specified in $code + * @return string The content with all links rewritten as per the logic specified in $code */ public static function urlRewriter($content, $code) { if(!is_callable($code)) { @@ -107,6 +144,7 @@ class HTTP { } // Replace attributes + $regExps = array(); $attribs = array("src","background","a" => "href","link" => "href", "base" => "href"); foreach($attribs as $tag => $attrib) { if(!is_numeric($tag)) $tagPrefix = "$tag "; @@ -132,6 +170,7 @@ class HTTP { } else { // Expose the $URL variable to be used by the $code expression $URL = $matches[2]; + array($URL); // Ensure $URL is available to scope of below code $rewritten = eval("return ($code);"); } return $matches[1] . $rewritten . $matches[3]; @@ -285,9 +324,12 @@ class HTTP { /** * Set the maximum age of this page in web caches, in seconds + * + * @param int $age */ public static function set_cache_age($age) { self::$cache_age = $age; + HTTPCacheControl::singleton()->setMaxAge($age); } public static function register_modification_date($dateString) { @@ -318,139 +360,129 @@ class HTTP { * deprecated; in these cases, the headers are output directly. */ public static function add_cache_headers($body = null) { - $cacheAge = self::$cache_age; - // Validate argument if($body && !($body instanceof SS_HTTPResponse)) { user_error("HTTP::add_cache_headers() must be passed an SS_HTTPResponse object", E_USER_WARNING); $body = null; } - // Development sites have frequently changing templates; this can get stuffed up by the code - // below. - if(Director::isDev()) $cacheAge = 0; - // The headers have been sent and we don't have an SS_HTTPResponse object to attach things to; no point in // us trying. - if(headers_sent() && !$body) return; + if(headers_sent() && !$body) { + return; + } + + // Warn if already assigned cache-control headers + if ($body && $body->getHeader('Cache-Control')) { + trigger_error( + 'Cache-Control header has already been set. ' + . 'Please use HTTPCacheControl API to set caching options instead.', + E_USER_WARNING + ); + return; + } + + $config = Config::inst()->forClass(__CLASS__); + + // Get current cache control state + $cacheControl = HTTPCacheControl::singleton(); + + // if http caching is disabled by config, disable it - used on dev environments due to frequently changing + // templates and other data. will be overridden by forced publicCache() or privateCache() calls + if ($config->get('disable_http_cache')) { + $cacheControl->disableCache(); + } // Populate $responseHeaders with all the headers that we want to build $responseHeaders = array(); - $config = Config::inst(); - $cacheControlHeaders = Config::inst()->get('HTTP', 'cache_control'); - - - // currently using a config setting to cancel this, seems to be so that the CMS caches ajax requests - if(function_exists('apache_request_headers') && $config->get(get_called_class(), 'cache_ajax_requests')) { - $requestHeaders = array_change_key_case(apache_request_headers(), CASE_LOWER); - - if(isset($requestHeaders['x-requested-with']) && $requestHeaders['x-requested-with']=='XMLHttpRequest') { - $cacheAge = 0; - } + // if no caching ajax requests, disable ajax if is ajax request + if (!$config->get('cache_ajax_requests') && Director::is_ajax()) { + $cacheControl->disableCache(); } - if($cacheAge > 0) { - $cacheControlHeaders['max-age'] = self::$cache_age; - - // Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information, - // defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter). - // Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should - // prefer the caching information indicated through the "Cache-Control" header. - $responseHeaders["Pragma"] = ""; - - // To do: User-Agent should only be added in situations where you *are* actually - // varying according to user-agent. - $vary = $config->get('HTTP', 'vary'); - if ($vary && strlen($vary)) { - $responseHeaders['Vary'] = $vary; - } - } - else { - if($body) { - // Grab header for checking. Unfortunately HTTPRequest uses a mistyped variant. - $contentDisposition = $body->getHeader('Content-disposition'); - if (!$contentDisposition) $contentDisposition = $body->getHeader('Content-Disposition'); - } - - if( - $body && - Director::is_https() && - isset($_SERVER['HTTP_USER_AGENT']) && - strstr($_SERVER['HTTP_USER_AGENT'], 'MSIE')==true && - strstr($contentDisposition, 'attachment;')==true - ) { - // IE6-IE8 have problems saving files when https and no-cache are used - // (http://support.microsoft.com/kb/323308) - // Note: this is also fixable by ticking "Do not save encrypted pages to disk" in advanced options. - $cacheControlHeaders['max-age'] = 3; - - // Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information, - // defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter). - // Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should - // prefer the caching information indicated through the "Cache-Control" header. - $responseHeaders["Pragma"] = ""; - } else { - $cacheControlHeaders['no-cache'] = "true"; - $cacheControlHeaders['no-store'] = "true"; - } + // Errors disable cache (unless some errors are cached intentionally by usercode) + if ($body && $body->isError()) { + // Even if publicCache(true) is specfied, errors will be uncachable + $cacheControl->disableCache(true); } - foreach($cacheControlHeaders as $header => $value) { - if(is_null($value)) { - unset($cacheControlHeaders[$header]); - } elseif((is_bool($value) && $value) || $value === "true") { - $cacheControlHeaders[$header] = $header; - } else { - $cacheControlHeaders[$header] = $header."=".$value; - } + // If sessions exist we assume that the responses should not be cached by CDNs / proxies as we are + // likely to be supplying information relevant to the current user only + if (Session::get_all()) { + // Don't force in case user code chooses to opt in to public caching + $cacheControl->privateCache(); } - $responseHeaders['Cache-Control'] = implode(', ', $cacheControlHeaders); - unset($cacheControlHeaders, $header, $value); + // split the current vary header into it's parts and merge it with the config settings + // to create a list of unique vary values + $configVary = $config->get('vary'); + $bodyVary = $body ? $body->getHeader('Vary') : ''; + $vary = self::combineVary($configVary, $bodyVary); + if ($vary) { + $responseHeaders['Vary'] = $vary; + } - if(self::$modification_date && $cacheAge > 0) { + // deal with IE6-IE8 problems with https and no-cache + $contentDisposition = null; + if($body) { + // Grab header for checking. Unfortunately HTTPRequest uses a mistyped variant. + $contentDisposition = $body->getHeader('Content-Disposition', true); + } + + if( + $body && + Director::is_https() && + isset($_SERVER['HTTP_USER_AGENT']) && + strstr($_SERVER['HTTP_USER_AGENT'], 'MSIE') == true && + strstr($contentDisposition, 'attachment;') == true && + ($cacheControl->hasDirective('no-cache') || $cacheControl->hasDirective('no-store')) + ) { + // IE6-IE8 have problems saving files when https and no-cache/no-store are used + // (http://support.microsoft.com/kb/323308) + // Note: this is also fixable by ticking "Do not save encrypted pages to disk" in advanced options. + $cacheControl->privateCache(true); + } + + if (self::$modification_date) { $responseHeaders["Last-Modified"] = self::gmt_date(self::$modification_date); + } + // if we can store the cache responses we should generate and send etags + if (!$cacheControl->hasDirective('no-store')) { // Chrome ignores Varies when redirecting back (http://code.google.com/p/chromium/issues/detail?id=79758) // which means that if you log out, you get redirected back to a page which Chrome then checks against // last-modified (which passes, getting a 304) // when it shouldn't be trying to use that page at all because it's the "logged in" version. // By also using and etag that includes both the modification date and all the varies // values which we also check against we can catch this and not return a 304 - $etagParts = array(self::$modification_date, serialize($_COOKIE)); - $etagParts[] = Director::is_https() ? 'https' : 'http'; - if (isset($_SERVER['HTTP_USER_AGENT'])) $etagParts[] = $_SERVER['HTTP_USER_AGENT']; - if (isset($_SERVER['HTTP_ACCEPT'])) $etagParts[] = $_SERVER['HTTP_ACCEPT']; + $etag = self::generateETag($body); + if ($etag) { + $responseHeaders['ETag'] = $etag; - $etag = sha1(implode(':', $etagParts)); - $responseHeaders["ETag"] = $etag; + // 304 response detection + if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { + // As above, only 304 if the last request had all the same varies values + // (or the etag isn't passed as part of the request - but with chrome it always is) + $matchesEtag = $_SERVER['HTTP_IF_NONE_MATCH'] == $etag; - // 304 response detection - if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { - $ifModifiedSince = strtotime(stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE'])); - - // As above, only 304 if the last request had all the same varies values - // (or the etag isn't passed as part of the request - but with chrome it always is) - $matchesEtag = !isset($_SERVER['HTTP_IF_NONE_MATCH']) || $_SERVER['HTTP_IF_NONE_MATCH'] == $etag; - - if($ifModifiedSince >= self::$modification_date && $matchesEtag) { - if($body) { - $body->setStatusCode(304); - $body->setBody(''); - } else { - header('HTTP/1.0 304 Not Modified'); - die(); + if ($matchesEtag) { + if ($body) { + $body->setStatusCode(304); + $body->setBody(''); + } else { + // this is wrong, we need to send the same vary headers and so on + header('HTTP/1.0 304 Not Modified'); + die(); + } } } } - - $expires = time() + $cacheAge; - $responseHeaders["Expires"] = self::gmt_date($expires); } - if(self::$etag) { - $responseHeaders['ETag'] = self::$etag; + if ($cacheControl->hasDirective('max-age')) { + $expires = time() + $cacheControl->getDirective('max-age'); + $responseHeaders["Expires"] = self::gmt_date($expires); } // etag needs to be a quoted string according to HTTP spec @@ -458,6 +490,9 @@ class HTTP { $responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']); } + // Merge with cache control headers + $responseHeaders = array_merge($responseHeaders, $cacheControl->generateHeaders()); + // Now that we've generated them, either output them or attach them to the SS_HTTPResponse as appropriate foreach($responseHeaders as $k => $v) { if($body) { @@ -471,23 +506,61 @@ class HTTP { } } + /** + * @param SS_HTTPResponse|string $response + * + * @return string|false + */ + protected static function generateETag($response) + { + if (self::$etag) { + return self::$etag; + } + if ($response instanceof SS_HTTPResponse) { + return $response->getHeader('ETag') ?: sprintf('"%s"', md5($response->getBody())); + } + if ($response) { + return sprintf('"%s"', md5($response)); + } + return false; + } /** * Return an {@link http://www.faqs.org/rfcs/rfc2822 RFC 2822} date in the * GMT timezone (a timestamp is always in GMT: the number of seconds * since January 1 1970 00:00:00 GMT) + * + * @param int $timestamp + * @return string */ public static function gmt_date($timestamp) { return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'; } - /* + /** * Return static variable cache_age in second + * + * @return int */ public static function get_cache_age() { return self::$cache_age; } + /** + * Combine vary strings + * + * @param string $vary,... Each vary as a separate arg + * @return string + */ + protected static function combineVary($vary) + { + $varies = array(); + foreach (func_get_args() as $arg) { + $argVaries = array_filter(preg_split("/\s*,\s*/", trim($arg))); + if ($argVaries) { + $varies = array_merge($varies, $argVaries); + } + } + return implode(', ', array_unique($varies)); + } } - - diff --git a/control/HTTPCacheControl.php b/control/HTTPCacheControl.php new file mode 100644 index 000000000..49ff1f984 --- /dev/null +++ b/control/HTTPCacheControl.php @@ -0,0 +1,442 @@ +state) { + $this->setDirectivesFromArray(Config::inst()->get('HTTP', 'cache_control')); + } + } + + /** + * Instruct the cache to apply a change with a given level, optionally + * modifying it with a force flag to increase priority of this action. + * + * If the apply level was successful, the change is made and the internal level + * threshold is incremented. + * + * @param int $level Priority of the given change + * @param bool $force If usercode has requested this action is forced to a higher priority. + * Note: Even if $force is set to true, other higher-priority forced changes can still + * cause a change to be rejected if it is below the required threshold. + * @return bool True if the given change is accepted, and that the internal + * level threshold is updated (if necessary) to the new minimum level. + */ + protected function applyChangeLevel($level, $force) + { + $forcingLevel = $level + ($force ? self::LEVEL_FORCED : 0); + if ($forcingLevel < $this->forcingLevel) { + return false; + } + $this->forcingLevel = $forcingLevel; + return true; + } + + /** + * Low level method for setting directives include any experimental or custom ones added via config + * + * @param string $directive + * @param string|bool $value + * + * @return $this + */ + public function setDirective($directive, $value = null) + { + // make sure the directive is in the list of allowed directives + $allowedDirectives = $this->config()->get('allowed_directives'); + $directive = strtolower($directive); + if (in_array($directive, $allowedDirectives)) { + $this->state[$directive] = $value; + } else { + throw new InvalidArgumentException('Directive ' . $directive . ' is not allowed'); + } + return $this; + } + + /** + * Low level method to set directives from an associative array + * + * @param array $directives + * + * @return $this + */ + public function setDirectivesFromArray($directives) + { + foreach ($directives as $directive => $value) { + // null values mean remove + if (is_null($value)) { + $this->removeDirective($directive); + } else { + // for legacy reasons we accept the string literal "true" as a bool + // a bool value of true means there is no explicit value for the directive + if ($value && (is_bool($value) || strtolower($value) === 'true')) { + $value = null; + } + $this->setDirective($directive, $value); + } + } + return $this; + } + + /** + * Low level method for removing directives + * + * @param string $directive + * + * @return $this + */ + public function removeDirective($directive) + { + unset($this->state[strtolower($directive)]); + return $this; + } + + /** + * Low level method to check if a directive is currently set + * + * @param string $directive + * + * @return bool + */ + public function hasDirective($directive) + { + return array_key_exists(strtolower($directive), $this->state); + } + + /** + * Low level method to get the value of a directive + * + * Note that `null` value is acceptable for a directive + * + * @param string $directive + * + * @return string|false|null + */ + public function getDirective($directive) + { + if ($this->hasDirective($directive)) { + return $this->state[strtolower($directive)]; + } + return false; + } + + /** + * The cache should not store anything about the client request or server response. + * + * Set the no-store directive (also removes max-age and s-maxage for consistency purposes) + * + * @param bool $noStore + * + * @return $this + */ + public function setNoStore($noStore = true) + { + if ($noStore) { + $this->setDirective('no-store'); + $this->removeDirective('max-age'); + $this->removeDirective('s-maxage'); + } else { + $this->removeDirective('no-store'); + } + return $this; + } + + /** + * Forces caches to submit the request to the origin server for validation before releasing a cached copy. + * + * @param bool $noCache + * + * @return $this + */ + public function setNoCache($noCache = true) + { + if ($noCache) { + $this->setDirective('no-cache'); + } else { + $this->removeDirective('no-cache'); + } + return $this; + } + + /** + * Specifies the maximum amount of time (seconds) a resource will be considered fresh. + * This directive is relative to the time of the request. + * + * @param int $age + * + * @return $this + */ + public function setMaxAge($age) + { + $this->setDirective('max-age', $age); + return $this; + } + + /** + * Overrides max-age or the Expires header, but it only applies to shared caches (e.g., proxies) + * and is ignored by a private cache. + * + * @param int $age + * + * @return $this + */ + public function setSharedMaxAge($age) + { + $this->setDirective('s-maxage', $age); + return $this; + } + + /** + * The cache must verify the status of the stale resources before using it and expired ones should not be used. + * + * @param bool $mustRevalidate + * + * @return $this + */ + public function setMustRevalidate($mustRevalidate = true) + { + if ($mustRevalidate) { + $this->setDirective('must-revalidate'); + } else { + $this->removeDirective('must-revalidate'); + } + return $this; + } + + /** + * Simple way to set cache control header to a cacheable state. + * Use this method over `publicCache()` if you are unsure about caching details. + * + * Removes `no-store` and `no-cache` directives; other directives will remain in place. + * Use alongside `setMaxAge()` to indicate caching. + * + * Does not set `public` directive. Usually, `setMaxAge()` is sufficient. Use `publicCache()` if this is explicitly required. + * See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private + * + * @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ + * @param bool $force Force the cache to public even if its unforced private or public + * @return $this + */ + public function enableCache($force = false) + { + // Only execute this if its forcing level is high enough + if (!$this->applyChangeLevel(self::LEVEL_ENABLED, $force)) { + SS_Log::log("Call to enableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); + return $this; + } + + $this->removeDirective('no-store'); + $this->removeDirective('no-cache'); + return $this; + } + + /** + * Simple way to set cache control header to a non-cacheable state. + * Use this method over `privateCache()` if you are unsure about caching details. + * Takes precendence over unforced `enableCache()`, `privateCache()` or `publicCache()` calls. + * + * Removes all state and replaces it with `no-cache, no-store, must-revalidate`. Although `no-store` is sufficient + * the others are added under recommendation from Mozilla (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Examples) + * + * Does not set `private` directive, use `privateCache()` if this is explicitly required. + * See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private + * + * @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ + * @param bool $force Force the cache to diabled even if it's forced private or public + * @return $this + */ + public function disableCache($force = false) + { + // Only execute this if its forcing level is high enough + if (!$this->applyChangeLevel(self::LEVEL_DISABLED, $force )) { + SS_Log::log("Call to disableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); + return $this; + } + + $this->state = array( + 'no-cache' => null, + 'no-store' => null, + 'must-revalidate' => null, + ); + return $this; + } + + /** + * Advanced way to set cache control header to a non-cacheable state. + * Indicates that the response is intended for a single user and must not be stored by a shared cache. + * A private cache (e.g. Web Browser) may store the response. Also removes `public` as this is a contradictory directive. + * + * @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ + * @param bool $force Force the cache to private even if it's forced public + * @return $this + */ + public function privateCache($force = false) + { + // Only execute this if its forcing level is high enough + if (!$this->applyChangeLevel(self::LEVEL_PRIVATE, $force)) { + SS_Log::log("Call to privateCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); + return $this; + } + + // Update the directives + $this->setDirective('private'); + $this->removeDirective('public'); + $this->removeDirective('no-cache'); + $this->removeDirective('no-store'); + return $this; + } + + /** + * Advanced way to set cache control header to a cacheable state. + * Indicates that the response may be cached by any cache. (eg: CDNs, Proxies, Web browsers) + * Also removes `private` as this is a contradictory directive + * + * @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ + * @param bool $force Force the cache to public even if it's private, unless it's been forced private + * @return $this + */ + public function publicCache($force = false) + { + // Only execute this if its forcing level is high enough + if (!$this->applyChangeLevel(self::LEVEL_PUBLIC, $force)) { + SS_Log::log("Call to publicCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); + return $this; + } + + $this->setDirective('public'); + $this->removeDirective('private'); + $this->removeDirective('no-cache'); + $this->removeDirective('no-store'); + return $this; + } + + /** + * Generate and add the `Cache-Control` header to a response object + * + * @param SS_HTTPResponse $response + * + * @return $this + */ + public function applyToResponse($response) + { + $headers = $this->generateHeaders(); + foreach ($headers as $name => $value) { + $response->addHeader($name, $value); + } + return $this; + } + + /** + * Generate the cache header + * + * @return string + */ + protected function generateCacheHeader() + { + $cacheControl = array(); + foreach ($this->state as $directive => $value) { + if (is_null($value)) { + $cacheControl[] = $directive; + } else { + $cacheControl[] = $directive . '=' . $value; + } + } + return implode(', ', $cacheControl); + } + + /** + * Generate all headers to output + * + * @return array + */ + public function generateHeaders() + { + return array( + 'Cache-Control' => $this->generateCacheHeader(), + ); + } + + /** + * Reset registered http cache control and force a fresh instance to be built + */ + public static function reset() { + Injector::inst()->unregisterNamedObject(__CLASS__); + } +} diff --git a/control/HTTPResponse.php b/control/HTTPResponse.php index 0b7f4c06f..15cb628bc 100644 --- a/control/HTTPResponse.php +++ b/control/HTTPResponse.php @@ -189,9 +189,15 @@ class SS_HTTPResponse { * @param string $header * @returns null|string */ - public function getHeader($header) { - if(isset($this->headers[$header])) - return $this->headers[$header]; + public function getHeader($header, $anyCase = false) { + if ($anyCase) { + $headers = array_change_key_case($this->headers, CASE_LOWER); + $header = strtolower($header); + } else { + $headers = $this->headers; + } + if(isset($headers[$header])) + return $headers[$header]; } /** diff --git a/control/VersionedRequestFilter.php b/control/VersionedRequestFilter.php index 605b24b54..7c71de1ff 100644 --- a/control/VersionedRequestFilter.php +++ b/control/VersionedRequestFilter.php @@ -34,6 +34,7 @@ class VersionedRequestFilter implements RequestFilter { if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) { throw new SS_HTTPResponse_Exception($response); } + HTTP::add_cache_headers($response); $response->output(); die; } @@ -44,6 +45,9 @@ class VersionedRequestFilter implements RequestFilter { } public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { + if (Versioned::current_stage() !== Versioned::LIVE && !HTTPCacheControl::singleton()->hasDirective('no-store')) { + HTTPCacheControl::singleton()->privateCache(true); + } return true; } diff --git a/dev/Log.php b/dev/Log.php index fe0c0ecaa..fafb9aeb1 100644 --- a/dev/Log.php +++ b/dev/Log.php @@ -131,8 +131,8 @@ class SS_Log { /** * Add a writer instance to the logger. * @param object $writer Zend_Log_Writer_Abstract instance - * @param const $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE - * @param $comparison Priority comparison operator. Acts on the integer values of the error + * @param int $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE + * @param string $comparison Priority comparison operator. Acts on the integer values of the error * levels, where more serious errors are lower numbers. By default this is "=", which means only * the given priority will be logged. Set to "<=" if you want to track errors of *at least* * the given priority. @@ -151,7 +151,7 @@ class SS_Log { * error code, error line, error context (backtrace). * * @param mixed $message Exception object or array of error context variables - * @param const $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE + * @param int $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE * @param mixed $extras Extra information to log in event */ public static function log($message, $priority, $extras = null) { diff --git a/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md b/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md index d3947241d..bf8f77aaf 100644 --- a/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md +++ b/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md @@ -65,7 +65,18 @@ application errors or edge cases. SilverStripe has no built-in protection for detailing with bots, captcha or other spam protection methods. This functionality is available as an additional [Spam Protection](https://github.com/silverstripe/silverstripe-spamprotection) module if required. The module provides an consistent API for allowing third-party spam protection handlers such as -[Recaptcha](http://www.google.com/recaptcha/intro/) and [Mollom](https://mollom.com/) to work within the `Form` API. +[Recaptcha](http://www.google.com/recaptcha/intro/) and [Mollom](https://mollom.com/) to work within the `Form` API. + +## Data disclosure through HTTP Caching (since 3.7.0) + +Forms, and particularly their responses, can contain sensitive data (such as when data is pre-populated or re-posted due +to validation errors). This data can inadvertently be stored either in a user's browser cache or in an intermediary +cache such as a CDN or other caching-proxy. If incorrect `Cache-Conrol` headers are used, private data may be cached and +accessible publicly through the CDN. + +To ensure this doesn't happen SilverStripe adds `Cache-Control: no-store, no-cache, must-revalidate` headers to any +forms that have validators or security tokens (all of them by default) applied to them; this ensures that CDNs +(and browsers) will not cache these pages. ## Related Documentation @@ -73,4 +84,4 @@ module if required. The module provides an consistent API for allowing third-par ## API Documentation -* [api:SecurityToken] \ No newline at end of file +* [api:SecurityToken] diff --git a/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md b/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md index 268a45a0f..d00fe5793 100644 --- a/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md +++ b/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md @@ -1,7 +1,175 @@ title: HTTP Cache Headers summary: Set the correct HTTP cache headers for your responses. -# Caching Headers +# HTTP Cache Headers + +## Overview + +By default, SilverStripe sends headers which signal to HTTP caches +that the response should be not considered cacheable. +HTTP caches can either be intermediary caches (e.g. CDNs and proxies), or clients (e.g. browsers). +The cache headers sent are `Cache-Control: no-store, no-cache, must-revalidate`; + +HTTP caching can be a great way to speed up your website, but needs to be properly applied. +Getting it wrong can accidentally expose draft pages or other protected content. +The [Google Web Fundamentals](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private) +are a great way to learn about HTTP caching. + +## Cache Control Headers + +### Overview + +In order to support developers in making safe choices around HTTP caching, +we're using a `HTTPCacheControl` class to control if a response +should be considered public or private. This is an abstraction on existing +lowlevel APIs like `HTTP::add_cache_headers()` and `SS_HTTPResponse->addHeader()`. + +The `HTTPCacheControl` API makes it easier to express your caching preferences +without running the risk of overriding essential core safety measures. +Most commonly, these APIs will prevent HTTP caching of draft content. + +It will also prevent caching of content generated with an active session, +since the system can't tell whether session data was used to vary the output. +In this case, it's up to the developer to opt-in to caching, +after ensuring that certain execution paths are safe despite of using sessions. + +The system behaviour does not guard against accidentally caching "private" content, +since there are too many variations under which output could be considered private +(e.g. a custom "approval" flag on a comment object). It is up to +the developer to ensure caching is used appropriately there. + +The `HTTPCacheControl` class supplements the `HTTP` helper class. +It comes with methods which let developers safely interact with the `Cache-Control` header. + +### disableCache() + +Simple way to set cache control header to a non-cacheable state. +Use this method over `privateCache()` if you are unsure about caching details. +Takes precendence over unforced `enableCache()`, `privateCache()` or `publicCache()` calls. + +Removes all state and replaces it with `no-cache, no-store, must-revalidate`. Although `no-store` is sufficient +the others are added under [recommendation from Mozilla](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Examples) + +Does not set `private` directive, use `privateCache()` if this is explicitly required +([details](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private)) + +### enableCache() + +Simple way to set cache control header to a cacheable state. +Use this method over `publicCache()` if you are unsure about caching details. + +Removes `no-store` and `no-cache` directives; other directives will remain in place. +Use alongside `setMaxAge()` to indicate caching. + +Does not set `public` directive. Usually, `setMaxAge()` is sufficient. Use `publicCache()` if this is explicitly required +([details](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private)) + +### privateCache() + +Advanced way to set cache control header to a non-cacheable state. +Indicates that the response is intended for a single user and must not be stored by a shared cache. +A private cache (e.g. Web Browser) may store the response. Also removes `public` as this is a contradictory directive. + +### publicCache() + +Advanced way to set cache control header to a cacheable state. +Indicates that the response may be cached by any cache. (eg: CDNs, Proxies, Web browsers) +Also removes `private` as this is a contradictory directive + +### Priority + +Each of these highlevel methods has a boolean `$force` parameter which determines +their application priority regardless of execution order. +The priority order is as followed, sorted in descending order +(earlier items will overrule later items): + + * `disableCache($force=true)` + * `privateCache($force=true)` + * `publicCache($force=true)` + * `enableCache($force=true)` + * `disableCache()` + * `privateCache()` + * `publicCache()` + * `enableCache()` + +## Cache Control Examples + +### Global opt-in for page content + +Enable caching for all page content (through `Page_Controller`). + +```php +class Page_Controller extends ContentController +{ + public function init() + { + HTTPCacheControl::inst() + ->enableCache() + ->setMaxAge(60); // 1 minute + + + parent::init(); + } +} +``` + +Note: SilverStripe will still override this preference when a session is active, +a [CSRF token](/developer_guides/forms/form_security) token is present, +or draft content has been requested. + +### Opt-out for a particular controller action + +If a controller output relies on session data, cookies, +permission checks or other triggers for conditional output, +you can disable caching either on a controller level +(through `init()`) or for a particular action. + +```php +class MyPage_Controller extends Page_Controller +{ + public function myprivateaction($request) + { + $response = $this->myPrivateResponse(); + HTTPCacheControl::inst() + ->disableCache(); + + return $response; + } +} +``` + +Note: SilverStripe will still override this preference when a session is active, +a [CSRF token](/developer_guides/forms/form_security) token is present, +or draft content has been requested. + +### Global opt-in, ignoring session (advanced) + +This can be helpful in situations where forms are embedded on the website. +SilverStripe will still override this preference when draft content has been requested. +CAUTION: This mode relies on a developer examining each execution path to ensure +that no session data is used to vary output. + +Use case: By default, forms include a [CSRF token](/developer_guides/forms/form_security) +which starts a session with a value that's unique to the visitor, which makes the output uncacheable. +But any subsequent requests by this visitor will also carry a session, leading to uncacheable output +for this visitor. This is the case even if the output does not contain any forms, +and does not vary for this particular visitor. + +```php +class Page_Controller extends ContentController +{ + public function init() + { + HTTPCacheControl::inst() + ->enableCache($force=true) // DANGER ZONE + ->setMaxAge(60); // 1 minute + + parent::init(); + } +} +``` + +## Defaults By default, PHP adds caching headers that make the page appear purely dynamic. This isn't usually appropriate for most sites, even ones that are updated reasonably frequently. SilverStripe overrides the default settings with the following @@ -14,40 +182,39 @@ headers: * Since a visitor cookie is set, the site won't be cached by proxies. * Ajax requests are never cached. -## Customizing Cache Headers +## Max Age -### HTTP::set_cache_age - - :::php - HTTP::set_cache_age(0); - -Used to set the max-age component of the cache-control line, in seconds. Set it to 0 to disable caching; the "no-cache" -clause in `Cache-Control` and `Pragma` will be included. - -### HTTP::register_modification_date +The cache age determines the lifetime of your cache, in seconds. +It only takes effect if you instruct the cache control +that your response is public in the first place (via `enableCache()` or via modifying the `HTTP.cache_control` defaults). :::php - HTTP::register_modification_date('2014-10-10'); + HTTPCacheControl::singleton() + ->setMaxAge(60) + +Note that `setMaxAge(0)` is NOT sufficient to disable caching in all cases. + +### Last Modified Used to set the modification date to something more recent than the default. [api:DataObject::__construct] calls [api:HTTP::register_modification_date(] whenever a record comes from the database ensuring the newest date is present. -### Vary: cache header + :::php + HTTP::register_modification_date('2014-10-10'); -By default, SilverStripe will output a `Vary` header (used by upstream caches for determining uniqueness) -that looks like +### Vary + +A `Vary` header tells caches which aspects of the response should be considered +when calculating a cache key, usually in addition to the full URL path. +By default, SilverStripe will output a `Vary` header with the following content: ``` -Cookie, X-Forwarded-Protocol, User-Agent, Accept +Vary: X-Requested-With, X-Forwarded-Protocol ``` -To change the value of the `Vary` header, you can change this value by specifying the header in configuration +To change the value of the `Vary` header, you can change this value by specifying the header in configuration. ```yml HTTP: vary: "" ``` - - - - diff --git a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md index ff434df04..554eaa6db 100644 --- a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md +++ b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md @@ -607,6 +607,15 @@ In a future release this behaviour will be changed to be on by default, and this variable will be no longer necessary, thus it will be necessary to always set SS_TRUSTED_PROXY_IPS if using a proxy. +## HTTP Caching Headers + +Caching is hard. If you get it wrong, private or draft content might leak +to unauthenticated users. We have created an abstraction which allows you to express +your intent around HTTP caching without worrying too much about the details. +See [/developer_guides/performances/http_cache_headers](Developer Guides > Performance > HTTP Cache Headers) +for details on how to apply caching safely, and read Google's +[Web Fundamentals on Caching](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching). + ## Related * [http://silverstripe.org/security-releases/](http://silverstripe.org/security-releases/) diff --git a/docs/en/04_Changelogs/3.7.0.md b/docs/en/04_Changelogs/3.7.0.md index 1a8fb72be..bbbfd4660 100644 --- a/docs/en/04_Changelogs/3.7.0.md +++ b/docs/en/04_Changelogs/3.7.0.md @@ -1,4 +1,4 @@ -# 3.7.0 (unreleased) +# 3.7.0 ## SilverStripe 3.7 and PHP 7.2 and Object subclasses @@ -67,3 +67,132 @@ Data that is not content sensitive can be cached across stages by simply opting $cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true)); ``` +## HTTP Cache Header changes + +### Overview + +In order to support developers in making safe choices around HTTP caching, +we've introduced a `HTTPCacheControl` class to control if a response +should be considered public or private. This is an abstraction on existing +lowlevel APIs like `HTTP::add_cache_headers()` and `SS_HTTPResponse->addHeader()`. + +This change introduces smaller but necessary changes to HTTP caching headers +sent by SilverStripe. If you are relying on HTTP caching in your implementation, +or use modules such as [silverstripe/controllerpolicy](https://github.com/silverstripe/silverstripe-controllerpolicy), +please review the implications of these changes below. + +In short, these APIs make it easier to express your caching preferences +without running the risk of overriding essential core safety measures. +Most commonly, these APIs will prevent HTTP caching of draft content. + +It will also prevent caching of content generated with an active session, +since the system can't tell whether session data was used to vary the output. +In this case, it's up to the developer to opt-in to caching, +after ensuring that certain execution paths are safe despite of using sessions. + +The system behaviour does not guard against accidentally caching "private" content, +since there are too many variations under which output could be considered private +(e.g. a custom "approval" flag on a comment object). It is up to +the developer to ensure caching is used appropriately there. + +By default, SilverStripe sends headers which signal to HTTP caches +that the response should be considered not cacheable. + +See [Developer Guide: Performance > HTTP Cache Headers](/developer_guide/performance/http_cache_headers) +for details on the new API. + +### Example Usage + +#### Global opt-in for page content + +Enable caching for all page content (through `Page_Controller`). + +```diff +class Page_Controller extends ContentController +{ + public function init() + { +- HTTP::set_cache_age(60); ++ HTTPCacheControl::inst() ++ ->enableCache() ++ ->setMaxAge(60); // 1 minute + + + parent::init(); + } +} +``` + +Note: SilverStripe will still override this preference when a session is active, +a [CSRF token](/developer_guides/forms/form_security) token is present, +or draft content has been requested. + +#### Opt-out for a particular controller action + +If a controller output relies on session data, cookies, +permission checks or other triggers for conditional output, +you can disable caching either on a controller level +(through `init()`) or for a particular action. + +```diff +class MyPage_Controller extends Page_Controller +{ + public function myprivateaction($request) + { + $response = $this->myPrivateResponse(); +- HTTP::set_cache_age(0); ++ HTTPCacheControl::inst() ++ ->disableCache(); + + + return $response; + } +} +``` + +Note: SilverStripe will still override this preference when a session is active, +a [CSRF token](/developer_guides/forms/form_security) token is present, +or draft content has been requested. + +#### Global opt-in, ignoring session (advanced) + +This can be helpful in situations where forms are embedded on the website. +SilverStripe will still override this preference when draft content has been requested. +CAUTION: This mode relies on a developer examining each execution path to ensure +that no session data is used to vary output. + +Use case: By default, forms include a [CSRF token](/developer_guides/forms/form_security) +which starts a session with a value that's unique to the visitor, which makes the output uncacheable. +But any subsequent requests by this visitor will also carry a session, leading to uncacheable output +for this visitor. This is the case even if the output does not contain any forms, +and does not vary for this particular visitor. + +```diff +class Page_Controller extends ContentController +{ + public function init() + { +- HTTP::set_cache_age(60); ++ HTTPCacheControl::inst() ++ ->enableCache($force=true) // DANGER ZONE ++ ->setMaxAge(60); // 1 minute + + + parent::init(); + } +} +``` + +### Detailed Changes + + * Added `Cache-Control: no-store` header to default responses, + to prevent intermediary HTTP proxies (e.g. CDNs) from caching unless developers opt-in + * Removed `Cache-Control: no-transform` header from default responses + * Removed `Vary: Cookie` as an unreliable cache buster, + rely on the existing `Cache-Control: no-store` defaults instead + * Removed `Vary: Accept`, since it's very uncommon to vary content on + the `Content-Type` headers submitted through the request, + and it can significantly decrease the likelyhood of a cache hit. + Note this is different from `Vary: Accept-Encoding`, + which is important for compression (e.g. gzip), and usually added by + other layers such as Apache's mod_gzip. diff --git a/forms/Form.php b/forms/Form.php index 03c8fb93c..d2f7c00e7 100644 --- a/forms/Form.php +++ b/forms/Form.php @@ -854,7 +854,9 @@ class Form extends RequestHandler { } // If we need to disable cache, do it - if ($needsCacheDisabled) HTTP::set_cache_age(0); + if ($needsCacheDisabled) { + HTTPCacheControl::singleton()->disableCache(true); + } $attrs = $this->getAttributes(); diff --git a/main.php b/main.php index e9fa011ee..27416bcb4 100644 --- a/main.php +++ b/main.php @@ -57,6 +57,9 @@ if (version_compare(phpversion(), '5.3.3', '<')) { */ require_once('core/Constants.php'); +// we handle our own cache headers in this application +session_cache_limiter(''); + // IIS will sometimes generate this. if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) { $_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL']; diff --git a/security/Security.php b/security/Security.php index 877c161b0..15a905800 100644 --- a/security/Security.php +++ b/security/Security.php @@ -240,6 +240,8 @@ class Security extends Controller implements TemplateGlobalProvider { if(!$controller) $controller = Controller::curr(); + HTTPCacheControl::singleton()->disableCache(true); + if(Director::is_ajax()) { $response = ($controller) ? $controller->getResponse() : new SS_HTTPResponse(); $response->setStatusCode(403); diff --git a/tests/control/HTTPCacheControlIntegrationTest.php b/tests/control/HTTPCacheControlIntegrationTest.php new file mode 100644 index 000000000..75e53c3e1 --- /dev/null +++ b/tests/control/HTTPCacheControlIntegrationTest.php @@ -0,0 +1,190 @@ +remove('HTTP', 'disable_http_cache'); + HTTPCacheControl::reset(); + } + + public function testFormCSRF() { + // CSRF sets caching to disabled + $response = $this->get('HTTPCacheControlIntegrationTest_SessionController/showform'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertNotContains('public', $header); + $this->assertNotContains('private', $header); + $this->assertContains('no-cache', $header); + $this->assertContains('no-store', $header); + $this->assertContains('must-revalidate', $header); + } + + public function testPublicForm() { + // Public forms (http get) allow public caching + $response = $this->get('HTTPCacheControlIntegrationTest_SessionController/showpublicform'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertContains('public', $header); + $this->assertContains('must-revalidate', $header); + $this->assertNotContains('no-cache', $response->getHeader('Cache-Control')); + $this->assertNotContains('no-store', $response->getHeader('Cache-Control')); + } + + public function testPrivateActionsError() + { + // disallowed private actions don't cache + $response = $this->get('HTTPCacheControlIntegrationTest_SessionController/privateaction'); + $header = $response->getHeader('Cache-Control'); + $this->assertTrue($response->isError()); + $this->assertContains('no-cache', $header); + $this->assertContains('no-store', $header); + $this->assertContains('must-revalidate', $header); + } + + public function testPrivateActionsAuthenticated() + { + $this->logInWithPermission('ADMIN'); + // Authenticated actions are private cache + $response = $this->get('HTTPCacheControlIntegrationTest_SessionController/privateaction'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertContains('private', $header); + $this->assertContains('must-revalidate', $header); + $this->assertNotContains('no-cache', $header); + $this->assertNotContains('no-store', $header); + } + + public function testPrivateCache() { + $response = $this->get('HTTPCacheControlIntegrationTest_RuleController/privateaction'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertContains('private', $header); + $this->assertContains('must-revalidate', $header); + $this->assertNotContains('no-cache', $header); + $this->assertNotContains('no-store', $header); + } + + public function testPublicCache() { + $response = $this->get('HTTPCacheControlIntegrationTest_RuleController/publicaction'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertContains('public', $header); + $this->assertContains('must-revalidate', $header); + $this->assertNotContains('no-cache', $header); + $this->assertNotContains('no-store', $header); + $this->assertContains('max-age=9000', $header); + } + + public function testDisabledCache() { + $response = $this->get('HTTPCacheControlIntegrationTest_RuleController/disabledaction'); + $header = $response->getHeader('Cache-Control'); + $this->assertFalse($response->isError()); + $this->assertNotContains('public', $header); + $this->assertNotContains('private', $header); + $this->assertContains('no-cache', $header); + $this->assertContains('no-store', $header); + $this->assertContains('must-revalidate', $header); + } +} + +/** + * Test caching based on session + */ +class HTTPCacheControlIntegrationTest_SessionController extends Controller implements TestOnly +{ + private static $allowed_actions = array( + 'showform', + 'privateaction', + 'publicaction', + 'showpublicform', + 'Form', + ); + + public function init() + { + parent::init(); + // Prefer public by default + HTTPCacheControl::singleton()->publicCache(); + } + + public function getContent() + { + return '

Hello world

'; + } + + public function showform() + { + // Form should be set to private due to CSRF + SecurityToken::enable(); + return $this->renderWith('BlankPage'); + } + + public function showpublicform() + { + // Public form doesn't use CSRF and thus no session usage + SecurityToken::disable(); + return $this->renderWith('BlankPage'); + } + + public function privateaction() + { + if (!Permission::check('ANYCODE')) { + $this->httpError(403, 'Not allowed'); + } + return 'ok'; + } + + public function publicaction() + { + return 'Hello!'; + } + + public function Form() + { + $form = new Form( + $this, + 'Form', + new FieldList(new TextField('Name')), + new FieldList(new FormAction('submit', 'Submit')) + ); + $form->setFormMethod('GET'); + return $form; + } +} + +/** + * Test caching based on specific http caching directives + */ +class HTTPCacheControlIntegrationTest_RuleController extends Controller implements TestOnly +{ + private static $allowed_actions = array( + 'privateaction', + 'publicaction', + 'disabledaction', + ); + + public function init() + { + parent::init(); + // Prefer public by default + HTTPCacheControl::singleton()->publicCache(); + } + + public function privateaction() { + HTTPCacheControl::singleton()->privateCache(); + return 'private content'; + } + + public function publicaction() { + HTTPCacheControl::singleton() + ->publicCache() + ->setMaxAge(9000); + return 'public content'; + } + + public function disabledaction() { + HTTPCacheControl::singleton()->disableCache(); + return 'uncached content'; + } +} diff --git a/tests/control/HTTPCacheControlTest.php b/tests/control/HTTPCacheControlTest.php new file mode 100644 index 000000000..27d96d2ed --- /dev/null +++ b/tests/control/HTTPCacheControlTest.php @@ -0,0 +1,66 @@ +assertTrue($this->isDisabled($hcc), 'caching starts as disabled'); + + $hcc->enableCache(); + $this->assertFalse($this->isDisabled($hcc)); + + $hcc->publicCache(); + $this->assertTrue($this->isPublic($hcc), 'public can be set at start'); + + $hcc->privateCache(); + $this->assertTrue($this->isPrivate($hcc), 'private overrides public'); + + $hcc->publicCache(); + $this->assertFalse($this->isPublic($hcc), 'public does not overrides private'); + + $hcc->disableCache(); + $this->assertTrue($this->isDisabled($hcc), 'disabled overrides private'); + + $hcc->privateCache(); + $this->assertFalse($this->isPrivate($hcc), 'private does not override disabled'); + + $hcc->enableCache(true); + $this->assertFalse($this->isDisabled($hcc)); + + $hcc->publicCache(true); + $this->assertTrue($this->isPublic($hcc), 'force-public overrides disabled'); + + $hcc->privateCache(); + $this->assertFalse($this->isPrivate($hcc), 'private does not overrdie force-public'); + + $hcc->privateCache(true); + $this->assertTrue($this->isPrivate($hcc), 'force-private overrides force-public'); + + $hcc->publicCache(true); + $this->assertFalse($this->isPublic($hcc), 'force-public does not override force-private'); + + $hcc->disableCache(true); + $this->assertTrue($this->isDisabled($hcc), 'force-disabled overrides force-private'); + + $hcc->publicCache(true); + $this->assertFalse($this->isPublic($hcc), 'force-public does not overrides force-disabled'); + } + + protected function isPrivate(HTTPCacheControl $hcc) + { + return $hcc->hasDirective('private') && !$hcc->hasDirective('public') && !$hcc->hasDirective('no-cache'); + } + + protected function isPublic(HTTPCacheControl $hcc) + { + return $hcc->hasDirective('public') && !$hcc->hasDirective('private') && !$hcc->hasDirective('no-cache'); + } + protected function isDisabled(HTTPCacheControl $hcc) + { + return $hcc->hasDirective('no-cache') && !$hcc->hasDirective('private') && !$hcc->hasDirective('public'); + } +} diff --git a/tests/control/HTTPTest.php b/tests/control/HTTPTest.php index b208f44ae..9beb38f15 100644 --- a/tests/control/HTTPTest.php +++ b/tests/control/HTTPTest.php @@ -7,27 +7,41 @@ */ class HTTPTest extends FunctionalTest { + public function setUp() + { + parent::setUp(); + // Remove dev-only config + Config::inst()->remove('HTTP', 'disable_http_cache'); + HTTPCacheControl::reset(); + } + public function testAddCacheHeaders() { $body = "

Mysite

"; $response = new SS_HTTPResponse($body, 200); - $this->assertEmpty($response->getHeader('Cache-Control')); - + HTTPCacheControl::singleton()->publicCache(); HTTP::set_cache_age(30); - HTTP::add_cache_headers($response); $this->assertNotEmpty($response->getHeader('Cache-Control')); - // Ensure max-age is zero for development. - Config::inst()->update('Director', 'environment_type', 'dev'); + // Ensure cache headers are set correctly when disabled via config (e.g. when dev) + Config::inst()->update('HTTP', 'disable_http_cache', true); + HTTPCacheControl::reset(); + HTTPCacheControl::singleton()->publicCache(); + HTTP::set_cache_age(30); $response = new SS_HTTPResponse($body, 200); HTTP::add_cache_headers($response); - $this->assertContains('max-age=0', $response->getHeader('Cache-Control')); + $this->assertContains('no-cache', $response->getHeader('Cache-Control')); + $this->assertContains('no-store', $response->getHeader('Cache-Control')); + $this->assertContains('must-revalidate', $response->getHeader('Cache-Control')); // Ensure max-age setting is respected in production. - Config::inst()->update('Director', 'environment_type', 'live'); + Config::inst()->remove('HTTP', 'disable_http_cache'); + HTTPCacheControl::reset(); + HTTPCacheControl::singleton()->publicCache(); + HTTP::set_cache_age(30); $response = new SS_HTTPResponse($body, 200); HTTP::add_cache_headers($response); - $this->assertContains('max-age=30', explode(', ', $response->getHeader('Cache-Control'))); + $this->assertContains('max-age=30', $response->getHeader('Cache-Control')); $this->assertNotContains('max-age=0', $response->getHeader('Cache-Control')); // Still "live": Ensure header's aren't overridden if already set (using purposefully different values). @@ -36,33 +50,40 @@ class HTTPTest extends FunctionalTest { 'Pragma' => 'no-cache', 'Cache-Control' => 'max-age=0, no-cache, no-store', ); + HTTPCacheControl::reset(); + HTTPCacheControl::singleton()->publicCache(); + HTTP::set_cache_age(30); $response = new SS_HTTPResponse($body, 200); foreach($headers as $name => $value) { $response->addHeader($name, $value); } - HTTP::add_cache_headers($response); - foreach($headers as $name => $value) { - $this->assertEquals($value, $response->getHeader($name)); - } - } + // Expect a warning if the header is already set + $this->setExpectedException( + 'PHPUnit_Framework_Error_Warning', + 'Cache-Control header has already been set. ' + . 'Please use HTTPCacheControl API to set caching options instead.' + ); + HTTP::add_cache_headers($response); + } public function testConfigVary() { $body = "

Mysite

"; $response = new SS_HTTPResponse($body, 200); - Config::inst()->update('Director', 'environment_type', 'live'); HTTP::set_cache_age(30); HTTP::add_cache_headers($response); $v = $response->getHeader('Vary'); $this->assertNotEmpty($v); - $this->assertContains("Cookie", $v); $this->assertContains("X-Forwarded-Protocol", $v); - $this->assertContains("User-Agent", $v); - $this->assertContains("Accept", $v); + $this->assertContains("X-Requested-With", $v); + $this->assertNotContains("Cookie", $v); + $this->assertNotContains("User-Agent", $v); + $this->assertNotContains("Accept", $v); Config::inst()->update('HTTP', 'vary', ''); + HTTPCacheControl::reset(); $response = new SS_HTTPResponse($body, 200); HTTP::add_cache_headers($response);