Completed
Push — master ( 64c77a...5f95cb )
by Machiel
02:55
created

HstsMiddleware::getStoreInstance()   C

Complexity

Conditions 7
Paths 8

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 7

Importance

Changes 0
Metric Value
dl 0
loc 22
ccs 10
cts 10
cp 1
rs 6.9811
c 0
b 0
f 0
cc 7
eloc 10
nc 8
nop 1
crap 7
1
<?php
2
namespace CheatCodes\GuzzleHsts;
3
4
use InvalidArgumentException;
5
use Psr\Http\Message\RequestInterface as Request;
6
use Psr\Http\Message\ResponseInterface as Response;
7
8
class HstsMiddleware
9
{
10
    /**
11
     * Next handler for the Guzzle middleware
12
     *
13
     * @var callable
14
     */
15
    private $nextHandler;
16
17
    /**
18
     * Store instances cache
19
     *
20
     * @var StoreInterface[]
21
     */
22
    private $storeInstances = [];
23
24
    /**
25
     * HstsMiddleware constructor
26
     *
27
     * @param callable $nextHandler Next handler to invoke.
28
     */
29 17
    public function __construct(callable $nextHandler)
30
    {
31 17
        $this->nextHandler = $nextHandler;
32 17
    }
33
34
    /**
35
     * Invoke the guzzle middleware
36
     *
37
     * @param Request $request
38
     * @param array   $options
39
     * @return \GuzzleHttp\Promise\PromiseInterface
40
     */
41 17
    public function __invoke(Request $request, array $options)
42
    {
43 17
        $fn = $this->nextHandler;
44
45 17
        $store = $this->getStoreInstance($options);
46
47 16
        $request = $this->handleHstsRewrite($request, $store);
48
49 16
        return $fn($request, $options)
50
            ->then(function (Response $response) use ($request, $store) {
51 16
                return $this->handleHstsRegistering($response, $request, $store);
52 16
            });
53
    }
54
55
    /**
56
     * Rewrite the requested uri if the requested host is a known HSTS host
57
     *
58
     * @param Request        $request
59
     * @param StoreInterface $store
60
     * @return Request
61
     */
62 16
    private function handleHstsRewrite(Request $request, StoreInterface $store)
63
    {
64 16
        $uri = $request->getUri();
65 16
        $domainName = $uri->getHost();
66
67 16
        if ($uri->getScheme() === 'http'
68 16
            && !$this->isIpAddress($domainName)
69 16
            && $this->isKnownHstsHosts($store, $uri->getHost())
70
        ) {
71 10
            $uri = $uri->withScheme('https');
72
73 10
            return $request->withUri($uri);
74
        }
75
76 16
        return $request;
77
    }
78
79
    /**
80
     * Register the host as a known HSTS host if the header is set properly
81
     *
82
     * @param Response       $response
83
     * @param Request        $request
84
     * @param StoreInterface $store
85
     * @return Response
86
     */
87 16
    private function handleHstsRegistering(Response $response, Request $request, StoreInterface $store)
88
    {
89 16
        $domainName = $request->getUri()->getHost();
90
91 16
        if ($request->getUri()->getScheme() === 'https'
92 16
            && $response->hasHeader('Strict-Transport-Security')
93 16
            && !$this->isIpAddress($domainName)
94
        ) {
95 12
            $header = $response->getHeader('Strict-Transport-Security');
96
97
            // Only process the first header, https://tools.ietf.org/html/rfc6797#section-8.1
98 12
            $policy = $this->parseHeader(array_shift($header));
99
100 12
            if (isset($policy['max-age'])) {
101 10
                if ($policy['max-age'] < 1) {
102 1
                    $store->delete($domainName);
103
                } else {
104
                    // Remove all unneeded data from the policy
105 10
                    $policy = array_intersect_key($policy, array_flip([
106 10
                        'max-age', 'includesubdomains',
107
                    ]));
108
109 10
                    $store->set($domainName, $policy['max-age'], $policy);
110
                }
111
            }
112
        }
113
114 16
        return $response;
115
    }
116
117
    /**
118
     * Check if the given domain is a known HSTS host
119
     *
120
     * @param StoreInterface $store
121
     * @param string         $domainName
122
     * @return bool
123
     */
124 15
    private function isKnownHstsHosts(StoreInterface $store, $domainName)
125
    {
126
        // Check full domain
127 15
        if ($store->get($domainName) !== false) {
128 10
            return true;
129
        }
130
131
        // Check superdomains
132 10
        $labels = explode('.', $domainName);
133 10
        $labelCount = count($labels);
134
135 10
        for ($i = 1; $i < $labelCount; ++$i) {
136 10
            $domainName = implode('.', array_slice($labels, $labelCount - $i));
137
138 10
            $policy = $store->get($domainName);
139
140 10
            if ($policy !== false && isset($policy['includesubdomains'])) {
141 2
                return true;
142
            }
143
        }
144
145 9
        return false;
146
    }
147
148
    /**
149
     * Get the store instance, possibly cached
150
     *
151
     * @param array $options
152
     * @return StoreInterface
153
     * @throws InvalidArgumentException
154
     */
155 17
    private function getStoreInstance(array $options)
156
    {
157
        // Get option or use the default store
158 17
        $store = isset($options['hsts_store']) ? $options['hsts_store'] : ArrayStore::class;
159
160
        // Just return the store if it is already an instance
161 17
        if ($store instanceof StoreInterface) {
162 1
            return $store;
163
        }
164
165
        // Instanciate new store or return already instanciated store
166 16
        if (is_string($store) && class_exists($store) && class_implements($store, StoreInterface::class)) {
167 15
            if (!isset($this->storeInstances[$store])) {
168 15
                $this->storeInstances[$store] = new $store();
169
            }
170
171 15
            return $this->storeInstances[$store];
172
        }
173
174 1
        throw new InvalidArgumentException('hsts_store must be an ' . StoreInterface::class .
175 1
            ' instance or the name of a class extending ' . StoreInterface::class);
176
    }
177
178
    /**
179
     * Parse the HSTS header
180
     *
181
     * @param string $header
182
     * @return array
183
     */
184 12
    private function parseHeader($header)
185
    {
186 12
        $directives = explode(';', $header);
187 12
        $parsed = [];
188
189 12
        foreach ($directives as $directive) {
190 12
            $directive = trim($directive);
191
192 12
            if (preg_match('/(?<name>.+?)=[\'"]?(?<value>.+?)[\'"]?$/', $directive, $matches)) {
193 10
                $name = strtolower($matches['name']);
194 10
                $value = $matches['value'];
195
            } else {
196 5
                $name = strtolower($directive);
197 5
                $value = true;
198
            }
199
200 12
            $parsed[$name] = $value;
201
        }
202
203 12
        return $parsed;
204
    }
205
206
    /**
207
     * Check if a host is an ip address
208
     *
209
     * @param string $host
210
     * @return bool
211
     */
212 16
    private function isIpAddress($host)
213
    {
214 16
        return filter_var($host, FILTER_VALIDATE_IP) !== false;
215
    }
216
217
    /**
218
     * Handler for registering the middleware
219
     *
220
     * @return \Closure
221
     */
222
    public static function handler()
223
    {
224 17
        return function (callable $handler) {
225 17
            return new self($handler);
226 17
        };
227
    }
228
}
229