diff --git a/README.md b/README.md index 84328d4..b4308db 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ ForceHttpsModule is a configurable module for force https in your ZF2/ZF3 Mvc Ap Features -------- -- [x] enable/disable force https. +- [x] Enable/disable force https. - [x] Force Https to All routes. - [x] Force Https to specific routes only. - [x] Keep headers, request method, and request body. +- [x] Enable/disable HTTP Strict Transport Security Header and set its value. Installation ------------ @@ -55,6 +56,11 @@ return [ 'checkout', 'payment' ], + // set HTTP Strict Transport Security Header + 'strict_transport_security' => [ + 'enable' => true, // set to false to disable it + 'value' => 'max-age=31536000', + ], ], ]; ``` diff --git a/config/force-https-module.local.php.dist b/config/force-https-module.local.php.dist index dfd86a1..4e8b517 100644 --- a/config/force-https-module.local.php.dist +++ b/config/force-https-module.local.php.dist @@ -8,5 +8,10 @@ return [ // a lists of specific routes to be https // only works if previous config 'force_all_routes' => false ], + // set HTTP Strict Transport Security Header + 'strict_transport_security' => [ + 'enable' => true, // set to false to disable it + 'value' => 'max-age=31536000', + ], ], ]; diff --git a/spec/Listener/ForceHttpsSpec.php b/spec/Listener/ForceHttpsSpec.php index 4197fff..8fe4d7b 100644 --- a/spec/Listener/ForceHttpsSpec.php +++ b/spec/Listener/ForceHttpsSpec.php @@ -27,6 +27,7 @@ ]); $eventManager = Double::instance(['implements' => EventManagerInterface::class]); + expect($eventManager)->toReceive('attach')->with(MvcEvent::EVENT_BOOTSTRAP, [$listener, 'setHttpStrictTransportSecurity']); expect($eventManager)->toReceive('attach')->with(MvcEvent::EVENT_ROUTE, [$listener, 'forceHttpsScheme']); $listener->attach($eventManager); @@ -43,6 +44,7 @@ ]); $eventManager = Double::instance(['implements' => EventManagerInterface::class]); + expect($eventManager)->not->toReceive('attach')->with(MvcEvent::EVENT_BOOTSTRAP, [$listener, 'setHttpStrictTransportSecurity']); expect($eventManager)->not->toReceive('attach')->with(MvcEvent::EVENT_ROUTE, [$listener, 'forceHttpsScheme']); $listener->attach($eventManager); @@ -57,6 +59,7 @@ ]); $eventManager = Double::instance(['implements' => EventManagerInterface::class]); + expect($eventManager)->not->toReceive('attach')->with(MvcEvent::EVENT_BOOTSTRAP, [$listener, 'setHttpStrictTransportSecurity']); expect($eventManager)->not->toReceive('attach')->with(MvcEvent::EVENT_ROUTE, [$listener, 'forceHttpsScheme']); $listener->attach($eventManager); @@ -65,6 +68,106 @@ }); + describe('->setHttpStrictTransportSecurity', function () { + + it('add Strict Transport Security Header with max-age=0 (expire) if uri scheme is http and no "strict_transport_security" defined/empty', function () { + + Console::overrideIsConsole(false); + $listener = new ForceHttps([ + 'enable' => true, + 'force_all_routes' => true, + 'force_specific_routes' => [], + ]); + + $mvcEvent = Double::instance(['extends' => MvcEvent::class, 'methods' => '__construct']); + $response = Double::instance(['extends' => Response::class]); + + allow($mvcEvent)->toReceive('getRequest', 'getUri', 'getScheme')->andReturn('http'); + allow($mvcEvent)->toReceive('getResponse')->andReturn($response); + allow($response)->toReceive('getHeaders', 'addHeaderLine')->with('Strict-Transport-Security: max-age=0'); + + expect($mvcEvent)->toReceive('getResponse'); + + $listener->setHttpStrictTransportSecurity($mvcEvent); + + }); + + it('add Strict Transport Security Header with max-age=0 (expire) if uri scheme is https and no "strict_transport_security" defined/empty', function () { + + Console::overrideIsConsole(false); + $listener = new ForceHttps([ + 'enable' => true, + 'force_all_routes' => true, + 'force_specific_routes' => [], + ]); + + $mvcEvent = Double::instance(['extends' => MvcEvent::class, 'methods' => '__construct']); + $response = Double::instance(['extends' => Response::class]); + + allow($mvcEvent)->toReceive('getRequest', 'getUri', 'getScheme')->andReturn('https'); + allow($mvcEvent)->toReceive('getResponse')->andReturn($response); + allow($response)->toReceive('getHeaders', 'addHeaderLine')->with('Strict-Transport-Security: max-age=0'); + + expect($mvcEvent)->toReceive('getResponse'); + + $listener->setHttpStrictTransportSecurity($mvcEvent); + + }); + + it('add Strict Transport Security Header if uri scheme is http and "strict_transport_security" defined', function () { + + Console::overrideIsConsole(false); + $listener = new ForceHttps([ + 'enable' => true, + 'force_all_routes' => true, + 'force_specific_routes' => [], + 'strict_transport_security' => [ + 'enable' => true, + 'value' => 'max-age=31536000', + ], + ]); + + $mvcEvent = Double::instance(['extends' => MvcEvent::class, 'methods' => '__construct']); + $response = Double::instance(['extends' => Response::class]); + + allow($mvcEvent)->toReceive('getRequest', 'getUri', 'getScheme')->andReturn('http'); + allow($mvcEvent)->toReceive('getResponse')->andReturn($response); + allow($response)->toReceive('getHeaders', 'addHeaderLine')->with('Strict-Transport-Security: max-age=31536000'); + + expect($mvcEvent)->toReceive('getResponse'); + + $listener->setHttpStrictTransportSecurity($mvcEvent); + + }); + + it('add Strict Transport Security Header if uri scheme is https and "strict_transport_security" defined', function () { + + Console::overrideIsConsole(false); + $listener = new ForceHttps([ + 'enable' => true, + 'force_all_routes' => true, + 'force_specific_routes' => [], + 'strict_transport_security' => [ + 'enable' => true, + 'value' => 'max-age=31536000', + ], + ]); + + $mvcEvent = Double::instance(['extends' => MvcEvent::class, 'methods' => '__construct']); + $response = Double::instance(['extends' => Response::class]); + + allow($mvcEvent)->toReceive('getRequest', 'getUri', 'getScheme')->andReturn('https'); + allow($mvcEvent)->toReceive('getResponse')->andReturn($response); + allow($response)->toReceive('getHeaders', 'addHeaderLine')->with('Strict-Transport-Security: max-age=31536000'); + + expect($mvcEvent)->toReceive('getResponse'); + + $listener->setHttpStrictTransportSecurity($mvcEvent); + + }); + + }); + describe('->forceHttpsScheme()', function () { it('not redirect if uri already has https scheme', function () { diff --git a/src/Listener/ForceHttps.php b/src/Listener/ForceHttps.php index 20f34b0..7e6eca6 100644 --- a/src/Listener/ForceHttps.php +++ b/src/Listener/ForceHttps.php @@ -33,9 +33,42 @@ public function attach(EventManagerInterface $events, $priority = 1) return; } + $this->listeners[] = $events->attach(MvcEvent::EVENT_BOOTSTRAP, [$this, 'setHttpStrictTransportSecurity']); $this->listeners[] = $events->attach(MvcEvent::EVENT_ROUTE, [$this, 'forceHttpsScheme']); } + /** + * Set The HTTP Strict Transport Security. + * + * @param MvcEvent $e + */ + public function setHttpStrictTransportSecurity(MvcEvent $e) + { + /** @var $request \Zend\Http\PhpEnvironment\Request */ + $request = $e->getRequest(); + $uriScheme = $request->getUri()->getScheme(); + + /** @var $response \Zend\Http\PhpEnvironment\Response */ + $response = $e->getResponse(); + + if ( + ($this->isSchemeHttps($uriScheme) || $this->isGoingToBeForcedToHttps($e)) && + isset( + $this->config['strict_transport_security']['enable'], + $this->config['strict_transport_security']['value'] + ) && + $this->config['strict_transport_security']['enable'] === true + ) { + $response->getHeaders() + ->addHeaderLine('Strict-Transport-Security: ' . $this->config['strict_transport_security']['value']); + return; + } + + // set max-age = 0 to strictly expire it, + $response->getHeaders() + ->addHeaderLine('Strict-Transport-Security: max-age=0'); + } + /** * Force Https Scheme handle. * @@ -47,16 +80,8 @@ public function forceHttpsScheme(MvcEvent $e) $request = $e->getRequest(); $uri = $request->getUri(); $uriScheme = $uri->getScheme(); - if ($uriScheme === 'https') { - return; - } - if (! $this->config['force_all_routes'] && - ! in_array( - $e->getRouteMatch()->getMatchedRouteName(), - $this->config['force_specific_routes'] - ) - ) { + if ($this->isSchemeHttps($uriScheme) || ! $this->isGoingToBeForcedToHttps($e)) { return; } @@ -64,6 +89,7 @@ public function forceHttpsScheme(MvcEvent $e) $response = $e->getResponse(); $httpsRequestUri = $uri->setScheme('https')->toString(); + // 307 keeps headers, request method, and request body $response->setStatusCode(307); $response->getHeaders() ->addHeaderLine('Location', $httpsRequestUri); @@ -71,4 +97,36 @@ public function forceHttpsScheme(MvcEvent $e) exit(0); } + + /** + * Is Scheme https ? + * + * @param string $uriScheme + * + * @return bool + */ + private function isSchemeHttps($uriScheme) + { + return $uriScheme === 'https'; + } + + /** + * Check Config if is going to be forced to https. + * + * @param MvcEvent $e + * @return bool + */ + private function isGoingToBeForcedToHttps(MvcEvent $e) + { + if (! $this->config['force_all_routes'] && + ! in_array( + $e->getRouteMatch()->getMatchedRouteName(), + $this->config['force_specific_routes'] + ) + ) { + return false; + } + + return true; + } }