diff --git a/src/main/php/web/filters/CORS.class.php b/src/main/php/web/filters/CORS.class.php new file mode 100755 index 00000000..c3453d9f --- /dev/null +++ b/src/main/php/web/filters/CORS.class.php @@ -0,0 +1,118 @@ +origins= $origins; + return $this; + } + + /** + * Sets the `Access-Control-Allow-Methods` header, specifying the method or + * methods allowed when accessing the resource. This is used in response to a + * preflight request. + * + * @param string|string[] $origins + */ + public function methods($methods): self { + $this->methods= is_string($methods) ? preg_split('/, ?/', $methods) : (array)$methods; + return $this; + } + + /** + * Sets the `Access-Control-Allow-Headers` header, used in response to a preflight + * request to indicate which headers can be used when making the actual request. + * + * @param string|string[] $headers + */ + public function headers($headers): self { + $this->headers= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers; + return $this; + } + + /** + * Sets the `Access-Control-Max-Age` header, indicating how long the results of + * a preflight request can be cached. Pass `null` to use the browser's default. + * + * @param ?int + */ + public function maxAge($seconds): self { + $this->maxAge= $seconds; + return $this; + } + + /** + * Sets the `Access-Control-Expose-Headers` header, adding the specified headers + * to the allowlist that JavaScript in browsers is allowed to access. + * + * @param string|string[] $headers + */ + public function expose($headers): self { + $this->expose= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers; + return $this; + } + + /** + * Sets the `Access-Control-Allow-Credentials` header, indicating whether or not + * the response to the request can be exposed when the credentials flag is true. + */ + public function credentials(bool $flag): self { + $this->credentials= $flag; + return $this; + } + + /** + * Filter request + * + * @param web.Request $request + * @param web.Response $response + * @param web.filters.Invocation $invocation + * @return var + */ + public function filter($request, $response, $invocation) { + $origin= $request->header('Origin'); + if (null !== $origin) { + $response->header('Vary', 'Origin'); + $response->header('Access-Control-Allow-Origin', is_string($this->origins ?? '') + ? $this->origins + : ($this->origins)($origin) + ); + + // All requests include expose-headers and credentials + $this->expose && $response->header('Access-Control-Expose-Headers', implode(', ', $this->expose)); + $this->credentials && $response->header('Access-Control-Allow-Credentials', 'true'); + + // Preflight requests also include methods, headers and max-age + if (null !== $request->header('Access-Control-Request-Method')) { + $this->methods && $response->header('Access-Control-Allow-Methods', implode(', ', $this->methods)); + $this->headers && $response->header('Access-Control-Allow-Headers', implode(', ', $this->headers)); + $this->maxAge && $response->header('Access-Control-Max-Age', $this->maxAge); + $response->answer(204); + return; + } + } + return $invocation->proceed($request, $response); + } +} \ No newline at end of file diff --git a/src/main/php/web/filters/Origins.class.php b/src/main/php/web/filters/Origins.class.php new file mode 100755 index 00000000..77702df1 --- /dev/null +++ b/src/main/php/web/filters/Origins.class.php @@ -0,0 +1,88 @@ +bases= (array)$bases; + } + + /** Returns origins matching localhost on `http` and `https` */ + public static function localhost(): self { + return new self(['http://localhost', 'https://localhost']); + } + + /** + * Matches ports + * + * - `null`: Directly match origins + * - `'*'`: Match any port + * - `80`: Match exactly port 80 + * - `[80, 443]`: Match ports 80 or 443 + * - `[null, 8080]`: Match absent port or port 8080 + * - `'8000..9000'`: Match anything in this port range + * + * @param ?string|array $ports + */ + public function ports($ports): self { + if (null === $ports) { + $this->ports= null; + } else { + $this->ports= []; + foreach (is_array($ports) ? $ports : [$ports] as $arg) { + if (null === $arg) { + $this->ports[]= function($port) { return null === $port; }; + } else if ('*' === $arg) { + $this->ports[]= function($port) { return true; }; + } else if (is_numeric($arg)) { + $cmp= (int)$arg; + $this->ports[]= function($port) use($cmp) { return $cmp === $port; }; + } else if (is_string($arg) && 2 === sscanf($arg, '%d..%d', $lo, $hi)) { + $this->ports[]= function($port) use($lo, $hi) { return $port >= $lo && $port <= $hi; }; + } else { + throw new IllegalArgumentException('Unexpected '.Objects::stringOf($arg)); + } + } + } + return $this; + } + + /** Retun whether a given origin matches */ + public function matches(string $origin): bool { + if (null === $this->ports) return in_array($origin, $this->bases); + + // Check for empty origins or origins w/o scheme + $s= strpos($origin, ':'); + if (false === $s) return false; + + // Check ports + $p= strrpos($origin, ':', $s + 1); + if (false === $p) { + if (!in_array($origin, $this->bases)) return false; + $port= null; + } else { + if (!in_array(substr($origin, 0, $p), $this->bases)) return false; + $port= (int)substr($origin, $p + 1); + } + + foreach ($this->ports as $check) { + if ($check($port)) return true; + } + return false; + } + + /** (...) overloading */ + public function __invoke($origin) { + return $this->matches($origin ?? '') ? $origin : null; + } +} \ No newline at end of file diff --git a/src/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php new file mode 100755 index 00000000..44a88273 --- /dev/null +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -0,0 +1,136 @@ + 'text/plain', 'Content-Length' => 9]; + + private function filter(CORS $fixture, $method, $uri, $headers= [], $body= null) { + $req= new Request(new TestInput($method, $uri, $headers, $body ?? '')); + $res= new Response(new TestOutput()); + $fixture->filter($req, $res, new Invocation(function($req, $res) { + $res->send('Completed', 'text/plain'); + })); + return $res; + } + + /** Returns fixture with the origin set */ + private function fixture(): CORS { + return (new CORS())->origins(self::ORIGIN); + } + + /** Values for preflight test */ + private function preflights() { + yield [$this->fixture(), []]; + yield [$this->fixture()->origins(function($origin) { return self::ORIGIN === $origin ? $origin : null; }), []]; + yield [$this->fixture()->origins('*'), ['Access-Control-Allow-Origin' => '*']]; + + // Methods + yield [$this->fixture()->methods(null), []]; + yield [$this->fixture()->methods([]), []]; + yield [$this->fixture()->methods('GET, POST'), ['Access-Control-Allow-Methods' => 'GET, POST']]; + yield [$this->fixture()->methods(['GET', 'POST']), ['Access-Control-Allow-Methods' => 'GET, POST']]; + + // Headers + yield [$this->fixture()->headers(null), []]; + yield [$this->fixture()->headers([]), []]; + yield [$this->fixture()->headers('X-Input'), ['Access-Control-Allow-Headers' => 'X-Input']]; + yield [$this->fixture()->headers(['X-Input']), ['Access-Control-Allow-Headers' => 'X-Input']]; + + // Age + yield [$this->fixture()->maxAge(null), []]; + yield [$this->fixture()->maxAge(0), []]; + yield [$this->fixture()->maxAge(86400), ['Access-Control-Max-Age' => '86400']]; + + // Expose + yield [$this->fixture()->expose(null), []]; + yield [$this->fixture()->expose([]), []]; + yield [$this->fixture()->expose('X-Output'), ['Access-Control-Expose-Headers' => 'X-Output']]; + yield [$this->fixture()->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; + + // Credentials + yield [$this->fixture()->credentials(false), []]; + yield [$this->fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + } + + /** Values for request test */ + private function requests() { + yield [$this->fixture(), []]; + + // Only included in preflight + yield [$this->fixture()->methods(['GET', 'POST']), []]; + yield [$this->fixture()->headers(['X-Input']), []]; + yield [$this->fixture()->maxAge(86400), []]; + + // Included in all requests + yield [$this->fixture()->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; + yield [$this->fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + } + + /** Values for allowing_origin_plain_or_with_port_3000 */ + private function origins() { + yield [self::ORIGIN, true]; + yield [self::ORIGIN.':3000', true]; + + // Not allowed + yield [self::ORIGIN.':443', false]; + yield [strtr(self::ORIGIN, ['http:' => 'https:']), false]; + yield ['http://localhost', false]; + yield ['', false]; + } + + #[Test] + public function can_create() { + new CORS(); + } + + #[Test] + public function request_without_origin_receives_no_cors() { + $response= $this->filter(new CORS(), 'GET', '/'); + + Assert::equals(200, $response->status()); + Assert::equals(self::RESPONSE, $response->headers()); + } + + #[Test, Values(from: 'preflights')] + public function preflight($fixture, $expected) { + $response= $this->filter($fixture, 'OPTIONS', '/', [ + 'Origin' => self::ORIGIN, + 'Access-Control-Request-Method' => 'GET', + 'Access-Control-Request-Headers' => 'X-Input', + ]); + + Assert::equals(204, $response->status()); + Assert::equals( + $expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN], + $response->headers() + ); + } + + #[Test, Values(from: 'requests')] + public function request($fixture, $expected) { + $response= $this->filter($fixture, 'GET', '/', ['Origin' => self::ORIGIN]); + + Assert::equals(200, $response->status()); + Assert::equals( + $expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN] + self::RESPONSE, + $response->headers() + ); + } + + #[Test, Values(from: 'origins')] + public function allowing_origin_plain_or_with_port_3000($origin, $allow) { + $fixture= (new CORS())->origins((new Origins(self::ORIGIN))->ports([null, 3000])); + $response= $this->filter($fixture, 'GET', '/', ['Origin' => $origin]); + + Assert::equals(200, $response->status()); + Assert::equals( + ($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin'] + self::RESPONSE, + $response->headers() + ); + } +} \ No newline at end of file diff --git a/src/test/php/web/unittest/filters/OriginsTest.class.php b/src/test/php/web/unittest/filters/OriginsTest.class.php new file mode 100755 index 00000000..9d6942e7 --- /dev/null +++ b/src/test/php/web/unittest/filters/OriginsTest.class.php @@ -0,0 +1,58 @@ +matches($origin)); + } + + #[Test, Values([['http://test', true], ['https://test', true], ['http://tests', false], ['', false]])] + public function matches_origins($origin, $expected) { + Assert::equals($expected, (new Origins(['http://test', 'https://test']))->matches($origin)); + } + + #[Test, Values([['http://test', true], ['http://test:80', false], ['http://locahost', false]])] + public function matches_no_port($origin, $expected) { + Assert::equals($expected, (new Origins('http://test'))->matches($origin)); + } + + #[Test, Values([['http://test', false], ['http://test:80', true], ['http://locahost:80', false]])] + public function matches_specified_port($origin, $expected) { + Assert::equals($expected, (new Origins('http://test'))->ports([80])->matches($origin)); + } + + #[Test, Values([['http://test', true], ['http://test:80', true], ['http://locahost:80', false]])] + public function matches_any_port($origin, $expected) { + Assert::equals($expected, (new Origins('http://test'))->ports('*')->matches($origin)); + } + + #[Test, Values([['http://test', true], ['http://test:80', true], ['http://locahost:80', false]])] + public function matches_plain_or_specified_port($origin, $expected) { + Assert::equals($expected, (new Origins('http://test'))->ports([null, 80])->matches($origin)); + } + + #[Test, Values([['http://test:80', true], ['http://test:8080', true], ['http://locahost:80', false]])] + public function matches_port_range($origin, $expected) { + Assert::equals($expected, (new Origins('http://test'))->ports([80, '8000..9000'])->matches($origin)); + } + + #[Test, Expect(IllegalArgumentException::class)] + public function illegal_port() { + (new Origins('http://test'))->ports('invalid'); + } +} \ No newline at end of file