Skip to content

Commit 9c1e915

Browse files
authored
Merge pull request #131 from xp-forge/feature/cors
Implement web.filters.CORS
2 parents 2d4c199 + 5ae6996 commit 9c1e915

4 files changed

Lines changed: 400 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php namespace web\filters;
2+
3+
use web\Filter;
4+
5+
/**
6+
* Cross-Origin Resource Sharing (CORS)
7+
*
8+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
9+
* @test web.unittest.filters.CORSTest
10+
*/
11+
class CORS implements Filter {
12+
public $origins= '';
13+
public $methods= [];
14+
public $headers= [];
15+
public $expose= [];
16+
public $maxAge= null;
17+
public $credentials= false;
18+
19+
/**
20+
* Sets the `Access-Control-Allow-Origin` header specifying either a single
21+
* origin which tells browsers to allow that origin to access the resource;
22+
* or else — for requests without credentials — the * wildcard tells browsers
23+
* to allow any origin to access the resource.
24+
*
25+
* @param string|function(string): string $origins
26+
*/
27+
public function origins($origins): self {
28+
$this->origins= $origins;
29+
return $this;
30+
}
31+
32+
/**
33+
* Sets the `Access-Control-Allow-Methods` header, specifying the method or
34+
* methods allowed when accessing the resource. This is used in response to a
35+
* preflight request.
36+
*
37+
* @param string|string[] $origins
38+
*/
39+
public function methods($methods): self {
40+
$this->methods= is_string($methods) ? preg_split('/, ?/', $methods) : (array)$methods;
41+
return $this;
42+
}
43+
44+
/**
45+
* Sets the `Access-Control-Allow-Headers` header, used in response to a preflight
46+
* request to indicate which headers can be used when making the actual request.
47+
*
48+
* @param string|string[] $headers
49+
*/
50+
public function headers($headers): self {
51+
$this->headers= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers;
52+
return $this;
53+
}
54+
55+
/**
56+
* Sets the `Access-Control-Max-Age` header, indicating how long the results of
57+
* a preflight request can be cached. Pass `null` to use the browser's default.
58+
*
59+
* @param ?int
60+
*/
61+
public function maxAge($seconds): self {
62+
$this->maxAge= $seconds;
63+
return $this;
64+
}
65+
66+
/**
67+
* Sets the `Access-Control-Expose-Headers` header, adding the specified headers
68+
* to the allowlist that JavaScript in browsers is allowed to access.
69+
*
70+
* @param string|string[] $headers
71+
*/
72+
public function expose($headers): self {
73+
$this->expose= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers;
74+
return $this;
75+
}
76+
77+
/**
78+
* Sets the `Access-Control-Allow-Credentials` header, indicating whether or not
79+
* the response to the request can be exposed when the credentials flag is true.
80+
*/
81+
public function credentials(bool $flag): self {
82+
$this->credentials= $flag;
83+
return $this;
84+
}
85+
86+
/**
87+
* Filter request
88+
*
89+
* @param web.Request $request
90+
* @param web.Response $response
91+
* @param web.filters.Invocation $invocation
92+
* @return var
93+
*/
94+
public function filter($request, $response, $invocation) {
95+
$origin= $request->header('Origin');
96+
if (null !== $origin) {
97+
$response->header('Vary', 'Origin');
98+
$response->header('Access-Control-Allow-Origin', is_string($this->origins ?? '')
99+
? $this->origins
100+
: ($this->origins)($origin)
101+
);
102+
103+
// All requests include expose-headers and credentials
104+
$this->expose && $response->header('Access-Control-Expose-Headers', implode(', ', $this->expose));
105+
$this->credentials && $response->header('Access-Control-Allow-Credentials', 'true');
106+
107+
// Preflight requests also include methods, headers and max-age
108+
if (null !== $request->header('Access-Control-Request-Method')) {
109+
$this->methods && $response->header('Access-Control-Allow-Methods', implode(', ', $this->methods));
110+
$this->headers && $response->header('Access-Control-Allow-Headers', implode(', ', $this->headers));
111+
$this->maxAge && $response->header('Access-Control-Max-Age', $this->maxAge);
112+
$response->answer(204);
113+
return;
114+
}
115+
}
116+
return $invocation->proceed($request, $response);
117+
}
118+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php namespace web\filters;
2+
3+
use lang\IllegalArgumentException;
4+
use util\Objects;
5+
6+
/**
7+
* Verifies origins
8+
*
9+
* @see https://github.com/xp-forge/web/pull/131#pullrequestreview-4444923370
10+
* @test web.unittest.filters.OriginsTest
11+
*/
12+
class Origins {
13+
public $bases;
14+
public $ports= null;
15+
16+
public function __construct($bases) {
17+
$this->bases= (array)$bases;
18+
}
19+
20+
/** Returns origins matching localhost on `http` and `https` */
21+
public static function localhost(): self {
22+
return new self(['http://localhost', 'https://localhost']);
23+
}
24+
25+
/**
26+
* Matches ports
27+
*
28+
* - `null`: Directly match origins
29+
* - `'*'`: Match any port
30+
* - `80`: Match exactly port 80
31+
* - `[80, 443]`: Match ports 80 or 443
32+
* - `[null, 8080]`: Match absent port or port 8080
33+
* - `'8000..9000'`: Match anything in this port range
34+
*
35+
* @param ?string|array $ports
36+
*/
37+
public function ports($ports): self {
38+
if (null === $ports) {
39+
$this->ports= null;
40+
} else {
41+
$this->ports= [];
42+
foreach (is_array($ports) ? $ports : [$ports] as $arg) {
43+
if (null === $arg) {
44+
$this->ports[]= function($port) { return null === $port; };
45+
} else if ('*' === $arg) {
46+
$this->ports[]= function($port) { return true; };
47+
} else if (is_numeric($arg)) {
48+
$cmp= (int)$arg;
49+
$this->ports[]= function($port) use($cmp) { return $cmp === $port; };
50+
} else if (is_string($arg) && 2 === sscanf($arg, '%d..%d', $lo, $hi)) {
51+
$this->ports[]= function($port) use($lo, $hi) { return $port >= $lo && $port <= $hi; };
52+
} else {
53+
throw new IllegalArgumentException('Unexpected '.Objects::stringOf($arg));
54+
}
55+
}
56+
}
57+
return $this;
58+
}
59+
60+
/** Retun whether a given origin matches */
61+
public function matches(string $origin): bool {
62+
if (null === $this->ports) return in_array($origin, $this->bases);
63+
64+
// Check for empty origins or origins w/o scheme
65+
$s= strpos($origin, ':');
66+
if (false === $s) return false;
67+
68+
// Check ports
69+
$p= strrpos($origin, ':', $s + 1);
70+
if (false === $p) {
71+
if (!in_array($origin, $this->bases)) return false;
72+
$port= null;
73+
} else {
74+
if (!in_array(substr($origin, 0, $p), $this->bases)) return false;
75+
$port= (int)substr($origin, $p + 1);
76+
}
77+
78+
foreach ($this->ports as $check) {
79+
if ($check($port)) return true;
80+
}
81+
return false;
82+
}
83+
84+
/** (...) overloading */
85+
public function __invoke($origin) {
86+
return $this->matches($origin ?? '') ? $origin : null;
87+
}
88+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php namespace web\unittest\filters;
2+
3+
use test\{Assert, Test, Values};
4+
use web\filters\{CORS, Origins, Invocation};
5+
use web\io\{TestInput, TestOutput};
6+
use web\{Filter, Request, Response};
7+
8+
class CORSTest {
9+
const ORIGIN= 'http://example.com';
10+
const RESPONSE= ['Content-Type' => 'text/plain', 'Content-Length' => 9];
11+
12+
private function filter(CORS $fixture, $method, $uri, $headers= [], $body= null) {
13+
$req= new Request(new TestInput($method, $uri, $headers, $body ?? ''));
14+
$res= new Response(new TestOutput());
15+
$fixture->filter($req, $res, new Invocation(function($req, $res) {
16+
$res->send('Completed', 'text/plain');
17+
}));
18+
return $res;
19+
}
20+
21+
/** Returns fixture with the origin set */
22+
private function fixture(): CORS {
23+
return (new CORS())->origins(self::ORIGIN);
24+
}
25+
26+
/** Values for preflight test */
27+
private function preflights() {
28+
yield [$this->fixture(), []];
29+
yield [$this->fixture()->origins(function($origin) { return self::ORIGIN === $origin ? $origin : null; }), []];
30+
yield [$this->fixture()->origins('*'), ['Access-Control-Allow-Origin' => '*']];
31+
32+
// Methods
33+
yield [$this->fixture()->methods(null), []];
34+
yield [$this->fixture()->methods([]), []];
35+
yield [$this->fixture()->methods('GET, POST'), ['Access-Control-Allow-Methods' => 'GET, POST']];
36+
yield [$this->fixture()->methods(['GET', 'POST']), ['Access-Control-Allow-Methods' => 'GET, POST']];
37+
38+
// Headers
39+
yield [$this->fixture()->headers(null), []];
40+
yield [$this->fixture()->headers([]), []];
41+
yield [$this->fixture()->headers('X-Input'), ['Access-Control-Allow-Headers' => 'X-Input']];
42+
yield [$this->fixture()->headers(['X-Input']), ['Access-Control-Allow-Headers' => 'X-Input']];
43+
44+
// Age
45+
yield [$this->fixture()->maxAge(null), []];
46+
yield [$this->fixture()->maxAge(0), []];
47+
yield [$this->fixture()->maxAge(86400), ['Access-Control-Max-Age' => '86400']];
48+
49+
// Expose
50+
yield [$this->fixture()->expose(null), []];
51+
yield [$this->fixture()->expose([]), []];
52+
yield [$this->fixture()->expose('X-Output'), ['Access-Control-Expose-Headers' => 'X-Output']];
53+
yield [$this->fixture()->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']];
54+
55+
// Credentials
56+
yield [$this->fixture()->credentials(false), []];
57+
yield [$this->fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']];
58+
}
59+
60+
/** Values for request test */
61+
private function requests() {
62+
yield [$this->fixture(), []];
63+
64+
// Only included in preflight
65+
yield [$this->fixture()->methods(['GET', 'POST']), []];
66+
yield [$this->fixture()->headers(['X-Input']), []];
67+
yield [$this->fixture()->maxAge(86400), []];
68+
69+
// Included in all requests
70+
yield [$this->fixture()->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']];
71+
yield [$this->fixture()->credentials(true), ['Access-Control-Allow-Credentials' => 'true']];
72+
}
73+
74+
/** Values for allowing_origin_plain_or_with_port_3000 */
75+
private function origins() {
76+
yield [self::ORIGIN, true];
77+
yield [self::ORIGIN.':3000', true];
78+
79+
// Not allowed
80+
yield [self::ORIGIN.':443', false];
81+
yield [strtr(self::ORIGIN, ['http:' => 'https:']), false];
82+
yield ['http://localhost', false];
83+
yield ['', false];
84+
}
85+
86+
#[Test]
87+
public function can_create() {
88+
new CORS();
89+
}
90+
91+
#[Test]
92+
public function request_without_origin_receives_no_cors() {
93+
$response= $this->filter(new CORS(), 'GET', '/');
94+
95+
Assert::equals(200, $response->status());
96+
Assert::equals(self::RESPONSE, $response->headers());
97+
}
98+
99+
#[Test, Values(from: 'preflights')]
100+
public function preflight($fixture, $expected) {
101+
$response= $this->filter($fixture, 'OPTIONS', '/', [
102+
'Origin' => self::ORIGIN,
103+
'Access-Control-Request-Method' => 'GET',
104+
'Access-Control-Request-Headers' => 'X-Input',
105+
]);
106+
107+
Assert::equals(204, $response->status());
108+
Assert::equals(
109+
$expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN],
110+
$response->headers()
111+
);
112+
}
113+
114+
#[Test, Values(from: 'requests')]
115+
public function request($fixture, $expected) {
116+
$response= $this->filter($fixture, 'GET', '/', ['Origin' => self::ORIGIN]);
117+
118+
Assert::equals(200, $response->status());
119+
Assert::equals(
120+
$expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN] + self::RESPONSE,
121+
$response->headers()
122+
);
123+
}
124+
125+
#[Test, Values(from: 'origins')]
126+
public function allowing_origin_plain_or_with_port_3000($origin, $allow) {
127+
$fixture= (new CORS())->origins((new Origins(self::ORIGIN))->ports([null, 3000]));
128+
$response= $this->filter($fixture, 'GET', '/', ['Origin' => $origin]);
129+
130+
Assert::equals(200, $response->status());
131+
Assert::equals(
132+
($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin'] + self::RESPONSE,
133+
$response->headers()
134+
);
135+
}
136+
}

0 commit comments

Comments
 (0)