Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HSTS support #301

Draft
wants to merge 2 commits into
base: 5.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/Interceptor/Hsts/CombinationHstsJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

class CombinationHstsJar implements HstsJar
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this would be a helpful thing to include - this would allow you to do something like CombinationHstsJar(InMemoryHstsJar, GooglePreloadHstsJar) to mimic standard browser behavior - anything on the preload list already would be promoted before you accessed it, but any new sites you access which advertise themselves as HSTS would be added to the jar.

{
/**
* @var ReadableHstsJar[]
*/
private readonly array $jars;

public function __construct(ReadableHstsJar ...$jars)
{
$this->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;
}
}
}
}
18 changes: 18 additions & 0 deletions src/Interceptor/Hsts/GooglePreloadListJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

final class GooglePreloadListJar extends ReadOnlyHstsJar
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads from the Google preload list https://hstspreload.org/ - not an official standard but the de facto preload list for most browsers (Chrome, Firefox, Opera, Safari, IE 11+ and Edge)

{
public function __construct()
{
$jar = new InMemoryHstsJar();
$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);
}
}
parent::__construct($jar);
}
}
44 changes: 44 additions & 0 deletions src/Interceptor/Hsts/HstsInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

use Amp\Cancellation;
use Amp\Http\Client\ApplicationInterceptor;
use Amp\Http\Client\DelegateHttpClient;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;

final class HstsInterceptor implements ApplicationInterceptor
{
public function __construct(private readonly ReadableHstsJar $hstsJar)
{
}

public function request(Request $request, Cancellation $cancellation, DelegateHttpClient $httpClient): Response
{
if ($request->getUri()->getScheme() === "http" && $this->hstsJar->test($request->getUri()->getHost())) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we have to test any other schemes than http here (ex. ws => wss)

$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;
}
}
17 changes: 17 additions & 0 deletions src/Interceptor/Hsts/HstsJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

interface HstsJar extends ReadableHstsJar
{
/**
* 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
*/
public function unregister(string $host): void;
}
39 changes: 39 additions & 0 deletions src/Interceptor/Hsts/InMemoryHstsJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

final class InMemoryHstsJar implements HstsJar
{
/**
* Array of host to either true (includeSubDomain) or false (no includeSubDomain)
* @var array<string,bool>
*/
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]);
}
}
15 changes: 15 additions & 0 deletions src/Interceptor/Hsts/ReadOnlyHstsJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

class ReadOnlyHstsJar implements ReadableHstsJar
{
public function __construct(private ReadableHstsJar $proxyJar)
{
}

public function test(string $host): bool
{
return $this->proxyJar->test($host);
}
}
11 changes: 11 additions & 0 deletions src/Interceptor/Hsts/ReadableHstsJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Amp\Http\Client\Interceptor\Hsts;

interface ReadableHstsJar
{
/**
* Test whether a host is registered as HSTS
*/
public function test(string $host): bool;
}