From 77b69e05736f862097dc07a6475f6ce8824d17b6 Mon Sep 17 00:00:00 2001 From: Katie Volz Date: Mon, 25 Apr 2022 21:45:22 +0000 Subject: [PATCH 1/2] Add HSTS support --- src/Interceptor/Hsts/CombinationHstsJar.php | 52 + src/Interceptor/Hsts/GooglePreloadListJar.php | 18 + src/Interceptor/Hsts/HstsInterceptor.php | 44 + src/Interceptor/Hsts/HstsJar.php | 17 + src/Interceptor/Hsts/InMemoryHstsJar.php | 39 + src/Interceptor/Hsts/ReadOnlyHstsJar.php | 15 + src/Interceptor/Hsts/ReadableHstsJar.php | 11 + .../Hsts/transport_security_state_static.json | 157497 +++++++++++++++ src/Interceptor/Hsts/update-google-list.sh | 1 + test/Interceptor/HstsTest.php | 33 + test/Interceptor/InterceptorTest.php | 5 + 11 files changed, 157732 insertions(+) create mode 100644 src/Interceptor/Hsts/CombinationHstsJar.php create mode 100644 src/Interceptor/Hsts/GooglePreloadListJar.php create mode 100644 src/Interceptor/Hsts/HstsInterceptor.php create mode 100644 src/Interceptor/Hsts/HstsJar.php create mode 100644 src/Interceptor/Hsts/InMemoryHstsJar.php create mode 100644 src/Interceptor/Hsts/ReadOnlyHstsJar.php create mode 100644 src/Interceptor/Hsts/ReadableHstsJar.php create mode 100644 src/Interceptor/Hsts/transport_security_state_static.json create mode 100644 src/Interceptor/Hsts/update-google-list.sh create mode 100644 test/Interceptor/HstsTest.php diff --git a/src/Interceptor/Hsts/CombinationHstsJar.php b/src/Interceptor/Hsts/CombinationHstsJar.php new file mode 100644 index 00000000..f0ced192 --- /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..3569cfe6 --- /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..2805c9ed --- /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..e50cf7c8 --- /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..44547000 --- /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)); From df400e87e049b550a3b58e243a3ec37ffa449c6f Mon Sep 17 00:00:00 2001 From: Katie Volz Date: Mon, 25 Apr 2022 22:17:37 +0000 Subject: [PATCH 2/2] Run php-cs-fixer --- src/Interceptor/Hsts/CombinationHstsJar.php | 4 ++-- src/Interceptor/Hsts/GooglePreloadListJar.php | 2 +- src/Interceptor/Hsts/HstsInterceptor.php | 2 +- src/Interceptor/Hsts/HstsJar.php | 4 ++-- src/Interceptor/Hsts/InMemoryHstsJar.php | 8 ++++---- src/Interceptor/Hsts/ReadableHstsJar.php | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Interceptor/Hsts/CombinationHstsJar.php b/src/Interceptor/Hsts/CombinationHstsJar.php index f0ced192..08e3d73a 100644 --- a/src/Interceptor/Hsts/CombinationHstsJar.php +++ b/src/Interceptor/Hsts/CombinationHstsJar.php @@ -25,7 +25,7 @@ public function test(string $host): bool } /** - * Registers into first HSTS jar that is not read-only + * Registers into first HSTS jar that is not read-only. */ public function register(string $host, bool $includeSubDomains = false): void { @@ -38,7 +38,7 @@ public function register(string $host, bool $includeSubDomains = false): void } /** - * Unregisters from all HSTS jars + * Unregisters from all HSTS jars. */ public function unregister(string $host): void { diff --git a/src/Interceptor/Hsts/GooglePreloadListJar.php b/src/Interceptor/Hsts/GooglePreloadListJar.php index 3569cfe6..cc061c52 100644 --- a/src/Interceptor/Hsts/GooglePreloadListJar.php +++ b/src/Interceptor/Hsts/GooglePreloadListJar.php @@ -7,7 +7,7 @@ final class GooglePreloadListJar extends ReadOnlyHstsJar public function __construct() { $jar = new InMemoryHstsJar(); - $entries = json_decode(file_get_contents(__DIR__ . "/transport_security_state_static.json"), associative: true)["entries"]; + $entries = \json_decode(\file_get_contents(__DIR__ . "/transport_security_state_static.json"), associative: true)["entries"]; foreach ($entries as $entry) { if (($entry["mode"] ?? null) === "force-https") { $jar->register($entry["name"], $entry["include_subdomains"] ?? false); diff --git a/src/Interceptor/Hsts/HstsInterceptor.php b/src/Interceptor/Hsts/HstsInterceptor.php index 2805c9ed..c1ae98ff 100644 --- a/src/Interceptor/Hsts/HstsInterceptor.php +++ b/src/Interceptor/Hsts/HstsInterceptor.php @@ -21,7 +21,7 @@ public function request(Request $request, Cancellation $cancellation, DelegateHt } $response = $httpClient->request($request, $cancellation); if ($strictTransportSecurity = $response->getHeader("Strict-Transport-Security")) { - $directives = array_map(trim(...), explode(";", $strictTransportSecurity)); + $directives = \array_map(trim(...), \explode(";", $strictTransportSecurity)); $includeSubDomains = false; $remove = false; foreach ($directives as $directive) { diff --git a/src/Interceptor/Hsts/HstsJar.php b/src/Interceptor/Hsts/HstsJar.php index e50cf7c8..5b3cc35e 100644 --- a/src/Interceptor/Hsts/HstsJar.php +++ b/src/Interceptor/Hsts/HstsJar.php @@ -5,13 +5,13 @@ interface HstsJar extends ReadableHstsJar { /** - * Mark a host as HSTS + * Mark a host as HSTS. * @param bool $includeSubDomains Whether the includeSubDomains directive was specified */ public function register(string $host, bool $includeSubDomains = false): void; /** - * Un-mark a host as HSTS, if it exists + * Un-mark a host as HSTS, if it exists. */ public function unregister(string $host): void; } diff --git a/src/Interceptor/Hsts/InMemoryHstsJar.php b/src/Interceptor/Hsts/InMemoryHstsJar.php index 7098c872..23c91c96 100644 --- a/src/Interceptor/Hsts/InMemoryHstsJar.php +++ b/src/Interceptor/Hsts/InMemoryHstsJar.php @@ -5,7 +5,7 @@ final class InMemoryHstsJar implements HstsJar { /** - * Array of host to either true (includeSubDomain) or false (no includeSubDomain) + * Array of host to either true (includeSubDomain) or false (no includeSubDomain). * @var array */ private array $hosts = []; @@ -14,15 +14,15 @@ public function test(string $host, bool $requireIncludeSubDomains = false): bool { if ( // Host must have been marked HSTS - array_key_exists($host, $this->hosts) && + \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) { + if (($dotPosition = \strpos($host, ".")) !== false) { // Test if a parent domain has been registered with includeSubDomains - return $this->test(substr($host, $dotPosition + 1), true); + return $this->test(\substr($host, $dotPosition + 1), true); } return false; } diff --git a/src/Interceptor/Hsts/ReadableHstsJar.php b/src/Interceptor/Hsts/ReadableHstsJar.php index 44547000..769dd4fb 100644 --- a/src/Interceptor/Hsts/ReadableHstsJar.php +++ b/src/Interceptor/Hsts/ReadableHstsJar.php @@ -5,7 +5,7 @@ interface ReadableHstsJar { /** - * Test whether a host is registered as HSTS + * Test whether a host is registered as HSTS. */ public function test(string $host): bool; }