From a21b1fbf665f0a99684890104d64c6d30fc7066e Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 26 Oct 2017 15:55:07 +1300 Subject: [PATCH] BUG Fix forceWWW and forceSSL not working in _config.php API Introduce CanonicalURLMiddleware BUG Fix Director::makeRelative() failing on multi-domain sites --- _config/requestprocessors.yml | 10 + src/Control/Director.php | 121 ++----- .../Middleware/CanonicalURLMiddleware.php | 331 ++++++++++++++++++ tests/php/Control/DirectorTest.php | 258 ++++++++++++-- 4 files changed, 602 insertions(+), 118 deletions(-) create mode 100644 src/Control/Middleware/CanonicalURLMiddleware.php diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml index 7f11a8b7cce..65aa88ef8f0 100644 --- a/_config/requestprocessors.yml +++ b/_config/requestprocessors.yml @@ -11,6 +11,7 @@ SilverStripe\Core\Injector\Injector: SessionMiddleware: '%$SilverStripe\Control\Middleware\SessionMiddleware' RequestProcessorMiddleware: '%$SilverStripe\Control\RequestProcessor' FlushMiddleware: '%$SilverStripe\Control\Middleware\FlushMiddleware' + CanonicalURLMiddleware: '%$SilverStripe\Control\Middleware\CanonicalURLMiddleware' SilverStripe\Control\Middleware\AllowedHostsMiddleware: properties: AllowedHosts: '`SS_ALLOWED_HOSTS`' @@ -37,3 +38,12 @@ 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 +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\Control\Middleware\CanonicalURLMiddleware: + properties: + ForceSSL: false + ForceWWW: false + diff --git a/src/Control/Director.php b/src/Control/Director.php index 2514e155b58..31614857868 100644 --- a/src/Control/Director.php +++ b/src/Control/Director.php @@ -3,6 +3,7 @@ namespace SilverStripe\Control; use SilverStripe\CMS\Model\SiteTree; +use SilverStripe\Control\Middleware\CanonicalURLMiddleware; use SilverStripe\Control\Middleware\HTTPMiddlewareAware; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Environment; @@ -242,7 +243,7 @@ public static function mockRequest( // If a port is mentioned in the absolute URL, be sure to add that into the HTTP host $newVars['_SERVER']['HTTP_HOST'] = isset($bits['port']) - ? $bits['host'].':'.$bits['port'] + ? $bits['host'] . ':' . $bits['port'] : $bits['host']; } @@ -595,53 +596,34 @@ public static function baseFolder() * Turns an absolute URL or folder into one that's relative to the root of the site. This is useful * when turning a URL into a filesystem reference, or vice versa. * - * @param string $url Accepts both a URL or a filesystem path. + * Note: You should check {@link Director::is_site_url()} if making an untrusted url relative prior + * to calling this function. * + * @param string $url Accepts both a URL or a filesystem path. * @return string */ public static function makeRelative($url) { // Allow for the accidental inclusion whitespace and // in the URL - $url = trim(preg_replace('#([^:])//#', '\\1/', $url)); - - $base1 = self::absoluteBaseURL(); - $baseDomain = substr($base1, strlen(self::protocol())); - - // Only bother comparing the URL to the absolute version if $url looks like a URL. - if (preg_match('/^https?[^:]*:\/\//', $url, $matches)) { - $urlProtocol = $matches[0]; - $urlWithoutProtocol = substr($url, strlen($urlProtocol)); - - // If we are already looking at baseURL, return '' (substr will return false) - if ($url == $base1) { - return ''; - } elseif (substr($url, 0, strlen($base1)) == $base1) { - return substr($url, strlen($base1)); - } elseif (substr($base1, -1) == "/" && $url == substr($base1, 0, -1)) { - // Convert http://www.mydomain.com/mysitedir to '' - return ""; - } + $url = preg_replace('#([^:])//#', '\\1/', trim($url)); - if (substr($urlWithoutProtocol, 0, strlen($baseDomain)) == $baseDomain) { - return substr($urlWithoutProtocol, strlen($baseDomain)); - } + // If using a real url, remove protocol / hostname / auth / port + if (preg_match('#^(?https?:)?//(?[^/]*)(?(/.*)?)$#i', $url, $matches)) { + $url = $matches['url']; } - // test for base folder, e.g. /var/www - $base2 = self::baseFolder(); - if (substr($url, 0, strlen($base2)) == $base2) { - return substr($url, strlen($base2)); + // Empty case + if (trim($url, '\\/') === '') { + return ''; } - // Test for relative base url, e.g. mywebsite/ if the full URL is http://localhost/mywebsite/ - $base3 = self::baseURL(); - if (substr($url, 0, strlen($base3)) == $base3) { - return substr($url, strlen($base3)); - } - - // Test for relative base url, e.g mywebsite/ if the full url is localhost/myswebsite - if (substr($url, 0, strlen($baseDomain)) == $baseDomain) { - return substr($url, strlen($baseDomain)); + // Remove base folder or url + foreach ([self::baseFolder(), self::baseURL()] as $base) { + // Ensure single / doesn't break comparison (unless it would make base empty) + $base = rtrim($base, '\\/') ?: $base; + if (stripos($url, $base) === 0) { + return ltrim(substr($url, strlen($base)), '\\/'); + } } // Nothing matched, fall back to returning the original URL @@ -697,10 +679,10 @@ public static function is_absolute_url($url) { // Strip off the query and fragment parts of the URL before checking if (($queryPosition = strpos($url, '?')) !== false) { - $url = substr($url, 0, $queryPosition-1); + $url = substr($url, 0, $queryPosition - 1); } if (($hashPosition = strpos($url, '#')) !== false) { - $url = substr($url, 0, $hashPosition-1); + $url = substr($url, 0, $hashPosition - 1); } $colonPosition = strpos($url, ':'); $slashPosition = strpos($url, '/'); @@ -809,7 +791,7 @@ public static function absoluteBaseURLWithAuth(HTTPRequest $request = null) $login = "$_SERVER[PHP_AUTH_USER]:$_SERVER[PHP_AUTH_PW]@"; } - return Director::protocol($request) . $login . static::host($request) . Director::baseURL(); + return Director::protocol($request) . $login . static::host($request) . Director::baseURL(); } /** @@ -855,62 +837,29 @@ protected static function force_redirect($destURL) * * @param array $patterns Array of regex patterns to match URLs that should be HTTPS. * @param string $secureDomain Secure domain to redirect to. Defaults to the current domain. - * @return bool true if already on SSL, false if doesn't match patterns (or cannot redirect) - * @throws HTTPResponse_Exception Throws exception with redirect, if successful + * @param HTTPRequest|null $request Request object to check */ - public static function forceSSL($patterns = null, $secureDomain = null) + public static function forceSSL($patterns = null, $secureDomain = null, HTTPRequest $request = null) { - // Already on SSL - if (static::is_https()) { - return true; - } - - // Can't redirect without a url - if (!isset($_SERVER['REQUEST_URI'])) { - return false; - } - + $handler = CanonicalURLMiddleware::singleton()->setForceSSL(true); if ($patterns) { - $matched = false; - $relativeURL = self::makeRelative(Director::absoluteURL($_SERVER['REQUEST_URI'])); - - // protect portions of the site based on the pattern - foreach ($patterns as $pattern) { - if (preg_match($pattern, $relativeURL)) { - $matched = true; - break; - } - } - if (!$matched) { - return false; - } + $handler->setForceSSLPatterns($patterns); } - - // if an domain is specified, redirect to that instead of the current domain - if (!$secureDomain) { - $secureDomain = static::host(); + if ($secureDomain) { + $handler->setForceSSLDomain($secureDomain); } - $url = 'https://' . $secureDomain . $_SERVER['REQUEST_URI']; - - // Force redirect - self::force_redirect($url); - return true; + $handler->throwRedirectIfNeeded($request); } /** * Force a redirect to a domain starting with "www." + * + * @param HTTPRequest $request */ - public static function forceWWW() + public static function forceWWW(HTTPRequest $request = null) { - if (!Director::isDev() && !Director::isTest() && strpos(static::host(), 'www') !== 0) { - $destURL = str_replace( - Director::protocol(), - Director::protocol() . 'www.', - Director::absoluteURL($_SERVER['REQUEST_URI']) - ); - - self::force_redirect($destURL); - } + $handler = CanonicalURLMiddleware::singleton()->setForceWWW(true); + $handler->throwRedirectIfNeeded($request); } /** @@ -947,7 +896,7 @@ public static function is_cli() * Can also be checked with {@link Director::isDev()}, {@link Director::isTest()}, and * {@link Director::isLive()}. * - * @return bool + * @return string */ public static function get_environment_type() { diff --git a/src/Control/Middleware/CanonicalURLMiddleware.php b/src/Control/Middleware/CanonicalURLMiddleware.php new file mode 100644 index 00000000000..a43dab80aa5 --- /dev/null +++ b/src/Control/Middleware/CanonicalURLMiddleware.php @@ -0,0 +1,331 @@ +forceSSLPatterns ?: []; + } + + /** + * @param array $forceSSLPatterns + * @return $this + */ + public function setForceSSLPatterns($forceSSLPatterns) + { + $this->forceSSLPatterns = $forceSSLPatterns; + return $this; + } + + /** + * @return string + */ + public function getForceSSLDomain() + { + return $this->forceSSLDomain; + } + + /** + * @param string $forceSSLDomain + * @return $this + */ + public function setForceSSLDomain($forceSSLDomain) + { + $this->forceSSLDomain = $forceSSLDomain; + return $this; + } + + /** + * @return bool + */ + public function getForceWWW() + { + return $this->forceWWW; + } + + /** + * @param bool $forceWWW + * @return $this + */ + public function setForceWWW($forceWWW) + { + $this->forceWWW = $forceWWW; + return $this; + } + + /** + * @return bool + */ + public function getForceSSL() + { + return $this->forceSSL; + } + + /** + * @param bool $forceSSL + * @return $this + */ + public function setForceSSL($forceSSL) + { + $this->forceSSL = $forceSSL; + return $this; + } + + /** + * Generate response for the given request + * + * @param HTTPRequest $request + * @param callable $delegate + * @return HTTPResponse + */ + public function process(HTTPRequest $request, callable $delegate) + { + // Handle any redirects + $redirect = $this->getRedirect($request); + if ($redirect) { + return $redirect; + } + + return $delegate($request); + } + + /** + * Given request object determine if we should redirect. + * + * @param HTTPRequest $request Pre-validated request object + * @return HTTPResponse|null If a redirect is needed return the response + */ + protected function getRedirect(HTTPRequest $request) + { + // Check global disable + if (!$this->isEnabled()) { + return null; + } + + // Get properties of current request + $host = $request->getHost(); + $scheme = $request->getScheme(); + + // Check https + if ($this->requiresSSL($request)) { + $scheme = 'https'; + + // Promote ssl domain if configured + $host = $this->getForceSSLDomain() ?: $host; + } + + // Check www. + if ($this->getForceWWW() && strpos($host, 'www.') !== 0) { + $host = "www.{$host}"; + } + + // No-op if no changes + if ($request->getScheme() === $scheme && $request->getHost() === $host) { + return null; + } + + // Rebuild url for request + $url = Controller::join_links("{$scheme}://{$host}", Director::baseURL(), $request->getURL(true)); + + // Force redirect + $response = new HTTPResponse(); + $response->redirect($url, $this->getRedirectType()); + HTTP::add_cache_headers($response); + return $response; + } + + /** + * Handles redirection to canonical urls outside of the main middleware chain + * using HTTPResponseException. + * Will not do anything if a current HTTPRequest isn't available + * + * @param HTTPRequest|null $request Allow HTTPRequest to be used for the base comparison + * @throws HTTPResponse_Exception + */ + public function throwRedirectIfNeeded(HTTPRequest $request = null) + { + $request = $this->getOrValidateRequest($request); + if (!$request) { + return; + } + $response = $this->getRedirect($request); + if ($response) { + throw new HTTPResponse_Exception($response); + } + } + + /** + * Return a valid request, if one is available, or null if none is available + * + * @param HTTPRequest $request + * @return mixed|null + */ + protected function getOrValidateRequest(HTTPRequest $request = null) + { + if ($request instanceof HTTPRequest) { + return $request; + } + if (Injector::inst()->has(HTTPRequest::class)) { + return Injector::inst()->get(HTTPRequest::class); + } + return null; + } + + /** + * Check if a redirect for SSL is necessary + * + * @param HTTPRequest $request + * @return bool + */ + protected function requiresSSL(HTTPRequest $request) + { + // Check if force SSL is enabled + if (!$this->getForceSSL()) { + return false; + } + + // Already on SSL + if ($request->getScheme() === 'https') { + return false; + } + + // Veto if any existing patterns fail + $patterns = $this->getForceSSLPatterns(); + if (!$patterns) { + return true; + } + + // Filter redirect based on url + $relativeURL = $request->getURL(true); + foreach ($patterns as $pattern) { + if (preg_match($pattern, $relativeURL)) { + return true; + } + } + + // No patterns match + return false; + } + + /** + * @return int + */ + public function getRedirectType() + { + return $this->redirectType; + } + + /** + * @param int $redirectType + * @return $this + */ + public function setRedirectType($redirectType) + { + $this->redirectType = $redirectType; + return $this; + } + + /** + * Get enabled flag, or list of environments to enable in + * + * @return array|bool + */ + public function getEnabledEnvs() + { + return $this->enabledEnvs; + } + + /** + * @param array|bool $enabledEnvs + * @return $this + */ + public function setEnabledEnvs($enabledEnvs) + { + $this->enabledEnvs = $enabledEnvs; + return $this; + } + + /** + * Ensure this middleware is enabled + */ + protected function isEnabled() + { + // At least one redirect must be enabled + if (!$this->getForceWWW() && !$this->getForceSSL()) { + return false; + } + + // Filter by env vars + $enabledEnvs = $this->getEnabledEnvs(); + if (is_bool($enabledEnvs)) { + return $enabledEnvs; + } + return empty($enabledEnvs) || in_array(Director::get_environment_type(), $enabledEnvs); + } +} diff --git a/tests/php/Control/DirectorTest.php b/tests/php/Control/DirectorTest.php index 01cc37d71d4..b4d8041d47c 100644 --- a/tests/php/Control/DirectorTest.php +++ b/tests/php/Control/DirectorTest.php @@ -7,7 +7,7 @@ use SilverStripe\Control\HTTPRequestBuilder; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; -use SilverStripe\Control\Middleware\HTTPMiddleware; +use SilverStripe\Control\Middleware\CanonicalURLMiddleware; use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter; use SilverStripe\Control\Middleware\TrustedProxyMiddleware; use SilverStripe\Control\RequestProcessor; @@ -30,6 +30,9 @@ protected function setUp() { parent::setUp(); Director::config()->set('alternate_base_url', 'http://www.mysite.com/'); + + // Ensure redirects enabled on all environments + CanonicalURLMiddleware::singleton()->setEnabledEnvs(true); $this->expectedRedirect = null; } @@ -239,26 +242,132 @@ public function testIsRelativeUrl() $this->assertTrue(Director::is_relative_url('/relative/#=http://test.com')); } - public function testMakeRelative() + /** + * @return array + */ + public function providerMakeRelative() { - $siteUrl = Director::absoluteBaseURL(); - $siteUrlNoProtocol = preg_replace('/https?:\/\//', '', $siteUrl); - - $this->assertEquals(Director::makeRelative("$siteUrl"), ''); - $this->assertEquals(Director::makeRelative("https://$siteUrlNoProtocol"), ''); - $this->assertEquals(Director::makeRelative("http://$siteUrlNoProtocol"), ''); - - $this->assertEquals(Director::makeRelative(" $siteUrl/testpage "), 'testpage'); - $this->assertEquals(Director::makeRelative("$siteUrlNoProtocol/testpage"), 'testpage'); - - $this->assertEquals(Director::makeRelative('ftp://test.com'), 'ftp://test.com'); - $this->assertEquals(Director::makeRelative('http://test.com'), 'http://test.com'); - - $this->assertEquals(Director::makeRelative('relative'), 'relative'); - $this->assertEquals(Director::makeRelative("$siteUrl/?url=http://test.com"), '?url=http://test.com'); + return [ + // Resilience to slash position + [ + 'http://www.mysite.com/base/folder', + 'http://www.mysite.com/base/folder', + '' + ], + [ + 'http://www.mysite.com/base/folder', + 'http://www.mysite.com/base/folder/', + '' + ], + [ + 'http://www.mysite.com/base/folder/', + 'http://www.mysite.com/base/folder', + '' + ], + [ + 'http://www.mysite.com/', + 'http://www.mysite.com/', + '' + ], + [ + 'http://www.mysite.com/', + 'http://www.mysite.com', + '' + ], + [ + 'http://www.mysite.com', + 'http://www.mysite.com/', + '' + ], + [ + 'http://www.mysite.com/base/folder', + 'http://www.mysite.com/base/folder/page', + 'page' + ], + [ + 'http://www.mysite.com/', + 'http://www.mysite.com/page/', + 'page/' + ], + // Parsing protocol safely + [ + 'http://www.mysite.com/base/folder', + 'https://www.mysite.com/base/folder', + '' + ], + [ + 'https://www.mysite.com/base/folder', + 'http://www.mysite.com/base/folder/testpage', + 'testpage' + ], + [ + 'http://www.mysite.com/base/folder', + '//www.mysite.com/base/folder/testpage', + 'testpage' + ], + // Dirty input + [ + 'http://www.mysite.com/base/folder', + ' https://www.mysite.com/base/folder/testpage ', + 'testpage' + ], + [ + 'http://www.mysite.com/base/folder', + '//www.mysite.com/base//folder/testpage//subpage', + 'testpage/subpage' + ], + // Non-http protocol isn't modified + [ + 'http://www.mysite.com/base/folder', + 'ftp://test.com', + 'ftp://test.com' + ], + // Alternate hostnames are redirected + [ + 'https://www.mysite.com/base/folder', + 'http://mysite.com/base/folder/testpage', + 'testpage' + ], + [ + 'http://www.otherdomain.com/base/folder', + '//www.mysite.com/base/folder/testpage', + 'testpage' + ], + // Base folder is found + [ + 'http://www.mysite.com/base/folder', + BASE_PATH . '/some/file.txt', + 'some/file.txt', + ], + // querystring is protected + [ + 'http://www.mysite.com/base/folder', + '//www.mysite.com/base//folder/testpage//subpage?args=hello', + 'testpage/subpage?args=hello' + ], + [ + 'http://www.mysite.com/base/folder', + '//www.mysite.com/base//folder/?args=hello', + '?args=hello' + ], + ]; + } - $this->assertEquals("test", Director::makeRelative("https://".$siteUrlNoProtocol."/test")); - $this->assertEquals("test", Director::makeRelative("http://".$siteUrlNoProtocol."/test")); + /** + * @dataProvider providerMakeRelative + * @param string $baseURL Site base URL + * @param string $requestURL Request URL + * @param string $relativeURL Expected relative URL + */ + public function testMakeRelative($baseURL, $requestURL, $relativeURL) + { + Director::config()->set('alternate_base_url', $baseURL); + $actualRelative = Director::makeRelative($requestURL); + $this->assertEquals( + $relativeURL, + $actualRelative, + "Expected relativeURL of {$requestURL} to be {$relativeURL}" + ); } /** @@ -412,43 +521,101 @@ public function testRouteParams() ); } + public function testForceWWW() + { + $this->expectExceptionRedirect('http://www.mysite.com/some-url'); + Director::mockRequest(function ($request) { + Injector::inst()->registerService($request, HTTPRequest::class); + Director::forceWWW(); + }, 'http://mysite.com/some-url'); + } + + public function testPromisedForceWWW() + { + Director::forceWWW(); + + // Flag is set but not redirected yet + $middleware = CanonicalURLMiddleware::singleton(); + $this->assertTrue($middleware->getForceWWW()); + + // Middleware forces the redirection eventually + /** @var HTTPResponse $response */ + $response = Director::mockRequest(function ($request) use ($middleware) { + return $middleware->process($request, function ($request) { + return null; + }); + }, 'http://mysite.com/some-url'); + + // Middleware returns non-exception redirect + $this->assertEquals('http://www.mysite.com/some-url', $response->getHeader('Location')); + $this->assertEquals(301, $response->getStatusCode()); + } + public function testForceSSLProtectsEntireSite() { $this->expectExceptionRedirect('https://www.mysite.com/some-url'); - Director::mockRequest(function () { + Director::mockRequest(function ($request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(); - }, '/some-url'); + }, 'http://www.mysite.com/some-url'); + } + + public function testPromisedForceSSL() + { + Director::forceSSL(); + + // Flag is set but not redirected yet + $middleware = CanonicalURLMiddleware::singleton(); + $this->assertTrue($middleware->getForceSSL()); + + // Middleware forces the redirection eventually + /** @var HTTPResponse $response */ + $response = Director::mockRequest(function ($request) use ($middleware) { + return $middleware->process($request, function ($request) { + return null; + }); + }, 'http://www.mysite.com/some-url'); + + // Middleware returns non-exception redirect + $this->assertEquals('https://www.mysite.com/some-url', $response->getHeader('Location')); + $this->assertEquals(301, $response->getStatusCode()); } public function testForceSSLOnTopLevelPagePattern() { // Expect admin to trigger redirect $this->expectExceptionRedirect('https://www.mysite.com/admin'); - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(array('/^admin/')); - }, '/admin'); + }, 'http://www.mysite.com/admin'); } public function testForceSSLOnSubPagesPattern() { // Expect to redirect to security login page $this->expectExceptionRedirect('https://www.mysite.com/Security/login'); - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(array('/^Security/')); - }, '/Security/login'); + }, 'http://www.mysite.com/Security/login'); } public function testForceSSLWithPatternDoesNotMatchOtherPages() { // Not on same url should not trigger redirect - Director::mockRequest(function () { - $this->assertFalse(Director::forceSSL(array('/^admin/'))); - }, Director::baseURL() . 'normal-page'); + $response = Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); + Director::forceSSL(array('/^admin/')); + }, 'http://www.mysite.com/normal-page'); + $this->assertNull($response, 'Non-matching patterns do not trigger redirect'); // nested url should not triger redirect either - Director::mockRequest(function () { - $this->assertFalse(Director::forceSSL(array('/^admin/', '/^Security/'))); - }, Director::baseURL() . 'just-another-page/sub-url'); + $response = Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); + Director::forceSSL(array('/^admin/', '/^Security/')); + }, 'http://www.mysite.com/just-another-page/sub-url'); + $this->assertNull($response, 'Non-matching patterns do not trigger redirect'); } public function testForceSSLAlternateDomain() @@ -456,8 +623,35 @@ public function testForceSSLAlternateDomain() // Ensure that forceSSL throws the appropriate exception $this->expectExceptionRedirect('https://secure.mysite.com/admin'); Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); return Director::forceSSL(array('/^admin/'), 'secure.mysite.com'); - }, Director::baseURL() . 'admin'); + }, 'http://www.mysite.com/admin'); + } + + /** + * Test that combined forceWWW and forceSSL combine safely + */ + public function testForceSSLandForceWWW() + { + Director::forceWWW(); + Director::forceSSL(); + + // Flag is set but not redirected yet + $middleware = CanonicalURLMiddleware::singleton(); + $this->assertTrue($middleware->getForceWWW()); + $this->assertTrue($middleware->getForceSSL()); + + // Middleware forces the redirection eventually + /** @var HTTPResponse $response */ + $response = Director::mockRequest(function ($request) use ($middleware) { + return $middleware->process($request, function ($request) { + return null; + }); + }, 'http://mysite.com/some-url'); + + // Middleware returns non-exception redirect + $this->assertEquals('https://www.mysite.com/some-url', $response->getHeader('Location')); + $this->assertEquals(301, $response->getStatusCode()); } /**