From f4ec8a93b1effc39b530ce6200c086241abd22a1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:28:17 +0200 Subject: [PATCH 1/9] Implement web.filters.CORS --- src/main/php/web/filters/CORS.class.php | 109 ++++++++++++++ .../web/unittest/filters/CORSTest.class.php | 135 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100755 src/main/php/web/filters/CORS.class.php create mode 100755 src/test/php/web/unittest/filters/CORSTest.class.php 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..0131b1a2 --- /dev/null +++ b/src/main/php/web/filters/CORS.class.php @@ -0,0 +1,109 @@ +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. + */ + public function maxAge(?int $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; + } + + public function filter($request, $response, $invocation) { + $origin= $request->header('Origin'); + if (null !== $origin) { + $response->header('Vary', 'Origin'); + $response->header('Access-Control-Allow-Origin', $this->origins instanceof Closure + ? ($this->origins)($origin) + : $this->origins + ); + + // 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/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php new file mode 100755 index 00000000..23b01fe3 --- /dev/null +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -0,0 +1,135 @@ + '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; + } + + /** Values for preflight test */ + private function preflights(): iterable { + $cors= (new CORS()->origins(self::ORIGIN)); + yield [$cors, []]; + yield [(clone $cors)->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []]; + yield [(clone $cors)->origins('*'), ['Access-Control-Allow-Origin' => '*']]; + + // Methods + yield [(clone $cors)->methods(null), []]; + yield [(clone $cors)->methods([]), []]; + yield [(clone $cors)->methods('GET, POST'), ['Access-Control-Allow-Methods' => 'GET, POST']]; + yield [(clone $cors)->methods(['GET', 'POST']), ['Access-Control-Allow-Methods' => 'GET, POST']]; + + // Headers + yield [(clone $cors)->headers(null), []]; + yield [(clone $cors)->headers([]), []]; + yield [(clone $cors)->headers('X-Input'), ['Access-Control-Allow-Headers' => 'X-Input']]; + yield [(clone $cors)->headers(['X-Input']), ['Access-Control-Allow-Headers' => 'X-Input']]; + + // Age + yield [(clone $cors)->maxAge(null), []]; + yield [(clone $cors)->maxAge(0), []]; + yield [(clone $cors)->maxAge(86400), ['Access-Control-Max-Age' => '86400']]; + + // Expose + yield [(clone $cors)->expose(null), []]; + yield [(clone $cors)->expose([]), []]; + yield [(clone $cors)->expose('X-Output'), ['Access-Control-Expose-Headers' => 'X-Output']]; + yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; + + // Credentials + yield [(clone $cors)->credentials(false), []]; + yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + } + + /** Values for request test */ + private function requests(): iterable { + $cors= (new CORS()->origins(self::ORIGIN)); + yield [$cors, []]; + + // Only included in preflight + yield [(clone $cors)->methods(['GET', 'POST']), []]; + yield [(clone $cors)->headers(['X-Input']), []]; + yield [(clone $cors)->maxAge(86400), []]; + + // Included in all requests + yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; + yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + } + + /** Values for allowing_origin_with_any_4_digit_port */ + private function origins(): iterable { + 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_with_any_4_digit_port($origin, $allow) { + $fixture= (new CORS())->origins(function($origin) { + return preg_match('/^'.preg_quote(self::ORIGIN, '/').'(:[0-9]{4})?$/', $origin) ? $origin : null; + }); + $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 From 188d87eda43619cb1cb9a01cd0835a665f28e10b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:33:26 +0200 Subject: [PATCH 2/9] Fix PHP < 8.4 --- src/test/php/web/unittest/filters/CORSTest.class.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php index 23b01fe3..18e87fee 100755 --- a/src/test/php/web/unittest/filters/CORSTest.class.php +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -20,7 +20,7 @@ private function filter(CORS $fixture, $method, $uri, $headers= [], $body= null) /** Values for preflight test */ private function preflights(): iterable { - $cors= (new CORS()->origins(self::ORIGIN)); + $cors= (new CORS())->origins(self::ORIGIN); yield [$cors, []]; yield [(clone $cors)->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []]; yield [(clone $cors)->origins('*'), ['Access-Control-Allow-Origin' => '*']]; @@ -55,7 +55,7 @@ private function preflights(): iterable { /** Values for request test */ private function requests(): iterable { - $cors= (new CORS()->origins(self::ORIGIN)); + $cors= (new CORS())->origins(self::ORIGIN); yield [$cors, []]; // Only included in preflight @@ -114,7 +114,7 @@ public function request($fixture, $expected) { Assert::equals(200, $response->status()); Assert::equals( - $expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN, ...self::RESPONSE], + $expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN] + self::RESPONSE, $response->headers() ); } @@ -128,7 +128,7 @@ public function allowing_origin_with_any_4_digit_port($origin, $allow) { Assert::equals(200, $response->status()); Assert::equals( - ($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin', ...self::RESPONSE], + ($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin'] + self::RESPONSE, $response->headers() ); } From 81a5e0bef24ddfbd6fe316a43da6d49d9b224c1d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:40:46 +0200 Subject: [PATCH 3/9] Fix PHP 7.0..7.3 --- .../web/unittest/filters/CORSTest.class.php | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php index 18e87fee..34aaf257 100755 --- a/src/test/php/web/unittest/filters/CORSTest.class.php +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -18,54 +18,57 @@ private function filter(CORS $fixture, $method, $uri, $headers= [], $body= null) 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(): iterable { - $cors= (new CORS())->origins(self::ORIGIN); - yield [$cors, []]; - yield [(clone $cors)->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []]; - yield [(clone $cors)->origins('*'), ['Access-Control-Allow-Origin' => '*']]; + yield [$this->fixture(), []]; + yield [$this->fixture()->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []]; + yield [$this->fixture()->origins('*'), ['Access-Control-Allow-Origin' => '*']]; // Methods - yield [(clone $cors)->methods(null), []]; - yield [(clone $cors)->methods([]), []]; - yield [(clone $cors)->methods('GET, POST'), ['Access-Control-Allow-Methods' => 'GET, POST']]; - yield [(clone $cors)->methods(['GET', 'POST']), ['Access-Control-Allow-Methods' => 'GET, POST']]; + 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 [(clone $cors)->headers(null), []]; - yield [(clone $cors)->headers([]), []]; - yield [(clone $cors)->headers('X-Input'), ['Access-Control-Allow-Headers' => 'X-Input']]; - yield [(clone $cors)->headers(['X-Input']), ['Access-Control-Allow-Headers' => 'X-Input']]; + 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 [(clone $cors)->maxAge(null), []]; - yield [(clone $cors)->maxAge(0), []]; - yield [(clone $cors)->maxAge(86400), ['Access-Control-Max-Age' => '86400']]; + yield [$this->fixture()->maxAge(null), []]; + yield [$this->fixture()->maxAge(0), []]; + yield [$this->fixture()->maxAge(86400), ['Access-Control-Max-Age' => '86400']]; // Expose - yield [(clone $cors)->expose(null), []]; - yield [(clone $cors)->expose([]), []]; - yield [(clone $cors)->expose('X-Output'), ['Access-Control-Expose-Headers' => 'X-Output']]; - yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; + 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 [(clone $cors)->credentials(false), []]; - yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + yield [$this->fixture()->credentials(false), []]; + yield [$this->fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; } /** Values for request test */ private function requests(): iterable { - $cors= (new CORS())->origins(self::ORIGIN); - yield [$cors, []]; + yield [$this->fixture(), []]; // Only included in preflight - yield [(clone $cors)->methods(['GET', 'POST']), []]; - yield [(clone $cors)->headers(['X-Input']), []]; - yield [(clone $cors)->maxAge(86400), []]; + yield [$this->fixture()->methods(['GET', 'POST']), []]; + yield [$this->fixture()->headers(['X-Input']), []]; + yield [$this->fixture()->maxAge(86400), []]; // Included in all requests - yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']]; - yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; + 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_with_any_4_digit_port */ From 2de59bb6f60b86bb3964a74316f78f2d17a76fbc Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:43:07 +0200 Subject: [PATCH 4/9] Fix short closures compatibility with PHP < 7.4 --- src/test/php/web/unittest/filters/CORSTest.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php index 34aaf257..027a4b6c 100755 --- a/src/test/php/web/unittest/filters/CORSTest.class.php +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -26,7 +26,7 @@ private function fixture(): CORS { /** Values for preflight test */ private function preflights(): iterable { yield [$this->fixture(), []]; - yield [$this->fixture()->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []]; + yield [$this->fixture()->origins(function($origin) { return self::ORIGIN === $origin ? $origin : null; }), []]; yield [$this->fixture()->origins('*'), ['Access-Control-Allow-Origin' => '*']]; // Methods From f677cb7c7da5e35f22ddc87e99b4d37257c9aa65 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:44:57 +0200 Subject: [PATCH 5/9] Fix "Generators may only declare a return type of Generator, Iterator or Traversable" --- src/test/php/web/unittest/filters/CORSTest.class.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/php/web/unittest/filters/CORSTest.class.php b/src/test/php/web/unittest/filters/CORSTest.class.php index 027a4b6c..addf404c 100755 --- a/src/test/php/web/unittest/filters/CORSTest.class.php +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -24,7 +24,7 @@ private function fixture(): CORS { } /** Values for preflight test */ - private function preflights(): iterable { + 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' => '*']]; @@ -58,7 +58,7 @@ private function preflights(): iterable { } /** Values for request test */ - private function requests(): iterable { + private function requests() { yield [$this->fixture(), []]; // Only included in preflight @@ -72,7 +72,7 @@ private function requests(): iterable { } /** Values for allowing_origin_with_any_4_digit_port */ - private function origins(): iterable { + private function origins() { yield [self::ORIGIN, true]; yield [self::ORIGIN.':3000', true]; From 478377e6cac3950fc2732376c3fcc626deed1eee Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 12:52:46 +0200 Subject: [PATCH 6/9] Fix nullable types not being supported in PHP 7.0 --- src/main/php/web/filters/CORS.class.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/php/web/filters/CORS.class.php b/src/main/php/web/filters/CORS.class.php index 0131b1a2..11cdedba 100755 --- a/src/main/php/web/filters/CORS.class.php +++ b/src/main/php/web/filters/CORS.class.php @@ -56,8 +56,10 @@ public function headers($headers): self { /** * 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(?int $seconds): self { + public function maxAge($seconds): self { $this->maxAge= $seconds; return $this; } From 3db13c6c952d748869abf90d9cceb0aced62fb23 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 13:19:38 +0200 Subject: [PATCH 7/9] QA: Add apidocs for filter() --- src/main/php/web/filters/CORS.class.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/php/web/filters/CORS.class.php b/src/main/php/web/filters/CORS.class.php index 11cdedba..189583b8 100755 --- a/src/main/php/web/filters/CORS.class.php +++ b/src/main/php/web/filters/CORS.class.php @@ -83,7 +83,15 @@ 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) { From 81440ffe68a6eab6a78ddf00de3be504c6bd1c9a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 15:02:12 +0200 Subject: [PATCH 8/9] Add web.filters.Origins --- src/main/php/web/filters/CORS.class.php | 7 +- src/main/php/web/filters/Origins.class.php | 88 +++++++++++++++++++ .../web/unittest/filters/CORSTest.class.php | 10 +-- .../unittest/filters/OriginsTest.class.php | 58 ++++++++++++ 4 files changed, 153 insertions(+), 10 deletions(-) create mode 100755 src/main/php/web/filters/Origins.class.php create mode 100755 src/test/php/web/unittest/filters/OriginsTest.class.php diff --git a/src/main/php/web/filters/CORS.class.php b/src/main/php/web/filters/CORS.class.php index 189583b8..c3453d9f 100755 --- a/src/main/php/web/filters/CORS.class.php +++ b/src/main/php/web/filters/CORS.class.php @@ -1,6 +1,5 @@ header('Origin'); if (null !== $origin) { $response->header('Vary', 'Origin'); - $response->header('Access-Control-Allow-Origin', $this->origins instanceof Closure - ? ($this->origins)($origin) - : $this->origins + $response->header('Access-Control-Allow-Origin', is_string($this->origins ?? '') + ? $this->origins + : ($this->origins)($origin) ); // All requests include expose-headers and credentials 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..8c729c1a --- /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`: Do not match ports + * - `'*'`: 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 index addf404c..44a88273 100755 --- a/src/test/php/web/unittest/filters/CORSTest.class.php +++ b/src/test/php/web/unittest/filters/CORSTest.class.php @@ -1,7 +1,7 @@ fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']]; } - /** Values for allowing_origin_with_any_4_digit_port */ + /** Values for allowing_origin_plain_or_with_port_3000 */ private function origins() { yield [self::ORIGIN, true]; yield [self::ORIGIN.':3000', true]; @@ -123,10 +123,8 @@ public function request($fixture, $expected) { } #[Test, Values(from: 'origins')] - public function allowing_origin_with_any_4_digit_port($origin, $allow) { - $fixture= (new CORS())->origins(function($origin) { - return preg_match('/^'.preg_quote(self::ORIGIN, '/').'(:[0-9]{4})?$/', $origin) ? $origin : null; - }); + 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()); 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 From 5ae69965a0b01e1996b8073e60274b9c17372031 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 7 Jun 2026 15:17:41 +0200 Subject: [PATCH 9/9] QA: Be more specific what `ports(null)` does [skip ci] --- src/main/php/web/filters/Origins.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/web/filters/Origins.class.php b/src/main/php/web/filters/Origins.class.php index 8c729c1a..77702df1 100755 --- a/src/main/php/web/filters/Origins.class.php +++ b/src/main/php/web/filters/Origins.class.php @@ -25,7 +25,7 @@ public static function localhost(): self { /** * Matches ports * - * - `null`: Do not match ports + * - `null`: Directly match origins * - `'*'`: Match any port * - `80`: Match exactly port 80 * - `[80, 443]`: Match ports 80 or 443