-
Notifications
You must be signed in to change notification settings - Fork 1
Implement web.filters.CORS #131
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
Merged
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f4ec8a9
Implement web.filters.CORS
thekid 188d87e
Fix PHP < 8.4
thekid 81a5e0b
Fix PHP 7.0..7.3
thekid 2de59bb
Fix short closures compatibility with PHP < 7.4
thekid f677cb7
Fix "Generators may only declare a return type of Generator, Iterator…
thekid 478377e
Fix nullable types not being supported in PHP 7.0
thekid 3db13c6
QA: Add apidocs for filter()
thekid 81440ff
Add web.filters.Origins
thekid 5ae6996
QA: Be more specific what `ports(null)` does [skip ci]
thekid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| <?php namespace web\filters; | ||
|
|
||
| use Closure; | ||
| use web\Filter; | ||
|
|
||
| /** | ||
| * Cross-Origin Resource Sharing (CORS) | ||
| * | ||
| * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS | ||
| * @test web.unittest.filters.CORSTest | ||
| */ | ||
| class CORS implements Filter { | ||
| public $origins= ''; | ||
| public $methods= []; | ||
| public $headers= []; | ||
| public $expose= []; | ||
| public $maxAge= null; | ||
| public $credentials= false; | ||
|
|
||
| /** | ||
| * Sets the `Access-Control-Allow-Origin` header specifying either a single | ||
| * origin which tells browsers to allow that origin to access the resource; | ||
| * or else — for requests without credentials — the * wildcard tells browsers | ||
| * to allow any origin to access the resource. | ||
| * | ||
| * @param string|function(string): string $origins | ||
| */ | ||
| public function origins($origins): self { | ||
| $this->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; | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| <?php namespace web\unittest\filters; | ||
|
|
||
| use test\{Assert, Test, Values}; | ||
| use web\filters\{CORS, Invocation}; | ||
| use web\io\{TestInput, TestOutput}; | ||
| use web\{Filter, Request, Response}; | ||
|
|
||
| class CORSTest { | ||
| const ORIGIN= 'http://example.com'; | ||
| const RESPONSE= ['Content-Type' => '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_with_any_4_digit_port */ | ||
| 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_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() | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.