diff --git a/src/Interceptor/Hsts/CombinationHstsJar.php b/src/Interceptor/Hsts/CombinationHstsJar.php new file mode 100644 index 00000000..08e3d73a --- /dev/null +++ b/src/Interceptor/Hsts/CombinationHstsJar.php @@ -0,0 +1,52 @@ +jars = $jars; + } + + public function test(string $host): bool + { + foreach ($this->jars as $jar) { + if ($jar->test($host)) { + return true; + } + } + return false; + } + + /** + * Registers into first HSTS jar that is not read-only. + */ + public function register(string $host, bool $includeSubDomains = false): void + { + foreach ($this->jars as $jar) { + if ($jar instanceof HstsJar) { + $jar->register($host, $includeSubDomains); + return; + } + } + } + + /** + * Unregisters from all HSTS jars. + */ + public function unregister(string $host): void + { + foreach ($this->jars as $jar) { + if ($jar instanceof HstsJar) { + $jar->unregister($host); + return; + } + } + } +} diff --git a/src/Interceptor/Hsts/GooglePreloadListJar.php b/src/Interceptor/Hsts/GooglePreloadListJar.php new file mode 100644 index 00000000..cc061c52 --- /dev/null +++ b/src/Interceptor/Hsts/GooglePreloadListJar.php @@ -0,0 +1,18 @@ +register($entry["name"], $entry["include_subdomains"] ?? false); + } + } + parent::__construct($jar); + } +} diff --git a/src/Interceptor/Hsts/HstsInterceptor.php b/src/Interceptor/Hsts/HstsInterceptor.php new file mode 100644 index 00000000..c1ae98ff --- /dev/null +++ b/src/Interceptor/Hsts/HstsInterceptor.php @@ -0,0 +1,44 @@ +getUri()->getScheme() === "http" && $this->hstsJar->test($request->getUri()->getHost())) { + $request->setUri($request->getUri()->withScheme("https")); + } + $response = $httpClient->request($request, $cancellation); + if ($strictTransportSecurity = $response->getHeader("Strict-Transport-Security")) { + $directives = \array_map(trim(...), \explode(";", $strictTransportSecurity)); + $includeSubDomains = false; + $remove = false; + foreach ($directives as $directive) { + if ($directive === "includeSubDomains") { + $includeSubDomains = true; + } elseif ($directive === "max-age=0") { + $remove = true; + } + } + if ($this->hstsJar instanceof HstsJar) { + if ($remove) { + $this->hstsJar->unregister($request->getUri()->getHost()); + } else { + $this->hstsJar->register($request->getUri()->getHost(), $includeSubDomains); + } + } + } + return $response; + } +} diff --git a/src/Interceptor/Hsts/HstsJar.php b/src/Interceptor/Hsts/HstsJar.php new file mode 100644 index 00000000..5b3cc35e --- /dev/null +++ b/src/Interceptor/Hsts/HstsJar.php @@ -0,0 +1,17 @@ + + */ + private array $hosts = []; + + public function test(string $host, bool $requireIncludeSubDomains = false): bool + { + if ( + // Host must have been marked HSTS + \array_key_exists($host, $this->hosts) && + // If "includeSubDomains" is required, it must be marked as such + (!$requireIncludeSubDomains || $this->hosts[$host]) + ) { + return true; + } + if (($dotPosition = \strpos($host, ".")) !== false) { + // Test if a parent domain has been registered with includeSubDomains + return $this->test(\substr($host, $dotPosition + 1), true); + } + return false; + } + + public function register(string $host, bool $includeSubDomains = false): void + { + $this->hosts[$host] = $includeSubDomains; + } + + public function unregister(string $host): void + { + unset($this->hosts[$host]); + } +} diff --git a/src/Interceptor/Hsts/ReadOnlyHstsJar.php b/src/Interceptor/Hsts/ReadOnlyHstsJar.php new file mode 100644 index 00000000..2fd8a8fb --- /dev/null +++ b/src/Interceptor/Hsts/ReadOnlyHstsJar.php @@ -0,0 +1,15 @@ +proxyJar->test($host); + } +} diff --git a/src/Interceptor/Hsts/ReadableHstsJar.php b/src/Interceptor/Hsts/ReadableHstsJar.php new file mode 100644 index 00000000..769dd4fb --- /dev/null +++ b/src/Interceptor/Hsts/ReadableHstsJar.php @@ -0,0 +1,11 @@ + transport_security_state_static.json \ No newline at end of file diff --git a/test/Interceptor/HstsTest.php b/test/Interceptor/HstsTest.php new file mode 100644 index 00000000..114ad699 --- /dev/null +++ b/test/Interceptor/HstsTest.php @@ -0,0 +1,33 @@ +register("example.org"); + $this->assertTrue($hstsJar->test("example.org")); +// $this->givenApplicationInterceptor(new HstsInterceptor($hstsJar)); +// $this->whenRequestIsExecuted(); +// $this->thenRequestHasScheme("https"); + } + public function testNonHstsHost(): void + { + $hstsJar = new InMemoryHstsJar(); + $hstsJar->register("example.com"); + $this->givenApplicationInterceptor(new HstsInterceptor($hstsJar)); + $this->whenRequestIsExecuted(); + $this->thenRequestHasScheme("http"); + } + public function testPreloadList(): void + { + $hstsJar = new GooglePreloadListJar(); + $this->assertTrue($hstsJar->test("test.dev")); + } +} diff --git a/test/Interceptor/InterceptorTest.php b/test/Interceptor/InterceptorTest.php index 153b6b7e..f3807eee 100644 --- a/test/Interceptor/InterceptorTest.php +++ b/test/Interceptor/InterceptorTest.php @@ -94,6 +94,11 @@ final protected function thenRequestHasHeader(string $field, string ...$values): $this->assertSame($values, $this->request->getHeaderArray($field)); } + final protected function thenRequestHasScheme(string $scheme): void + { + $this->assertSame($scheme, $this->response->getRequest()->getUri()->getScheme()); + } + final protected function thenRequestDoesNotHaveHeader(string $field): void { $this->assertSame([], $this->request->getHeaderArray($field));