Skip to content

Commit 240dc06

Browse files
committed
Implement web.filters.CORS
1 parent 2e4f615 commit 240dc06

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php namespace web\filters;
2+
3+
use Closure;
4+
use web\Filter;
5+
6+
/**
7+
* Cross-Origin Resource Sharing (CORS)
8+
*
9+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
10+
* @test web.unittest.filters.CORSTest
11+
*/
12+
class CORS implements Filter {
13+
public $origins= '';
14+
public $methods= [];
15+
public $headers= [];
16+
public $expose= [];
17+
public $maxAge= null;
18+
public $credentials= false;
19+
20+
/**
21+
* Sets the `Access-Control-Allow-Origin` header specifying either a single
22+
* origin which tells browsers to allow that origin to access the resource;
23+
* or else — for requests without credentials — the * wildcard tells browsers
24+
* to allow any origin to access the resource.
25+
*
26+
* @param string|function(string): string $origins
27+
*/
28+
public function origins($origins): self {
29+
$this->origins= $origins;
30+
return $this;
31+
}
32+
33+
/**
34+
* Sets the `Access-Control-Allow-Methods` header, specifying the method or
35+
* methods allowed when accessing the resource. This is used in response to a
36+
* preflight request.
37+
*
38+
* @param string|string[] $origins
39+
*/
40+
public function methods($methods): self {
41+
$this->methods= is_string($methods) ? preg_split('/, ?/', $methods) : (array)$methods;
42+
return $this;
43+
}
44+
45+
/**
46+
* Sets the `Access-Control-Allow-Headers` header, used in response to a preflight
47+
* request to indicate which headers can be used when making the actual request.
48+
*
49+
* @param string|string[] $headers
50+
*/
51+
public function headers($headers): self {
52+
$this->headers= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers;
53+
return $this;
54+
}
55+
56+
/**
57+
* Sets the `Access-Control-Max-Age` header, indicating how long the results of
58+
* a preflight request can be cached. Pass `null` to use the browser's default.
59+
*/
60+
public function maxAge(?int $seconds): self {
61+
$this->maxAge= $seconds;
62+
return $this;
63+
}
64+
65+
/**
66+
* Sets the `Access-Control-Expose-Headers` header, adding the specified headers
67+
* to the allowlist that JavaScript in browsers is allowed to access.
68+
*
69+
* @param string|string[] $headers
70+
*/
71+
public function expose($headers): self {
72+
$this->expose= is_string($headers) ? preg_split('/, ?/', $headers) : (array)$headers;
73+
return $this;
74+
}
75+
76+
/**
77+
* Sets the `Access-Control-Allow-Credentials` header, indicating whether or not
78+
* the response to the request can be exposed when the credentials flag is true.
79+
*/
80+
public function credentials(bool $flag): self {
81+
$this->credentials= $flag;
82+
return $this;
83+
}
84+
85+
public function filter($request, $response, $invocation) {
86+
$origin= $request->header('Origin');
87+
if (null !== $origin) {
88+
$response->header('Vary', 'Origin');
89+
$response->header('Access-Control-Allow-Origin', $this->origins instanceof Closure
90+
? ($this->origins)($origin)
91+
: $this->origins
92+
);
93+
94+
// All requests include expose-headers and credentials
95+
$this->expose && $response->header('Access-Control-Expose-Headers', implode(', ', $this->expose));
96+
$this->credentials && $response->header('Access-Control-Allow-Credentials', 'true');
97+
98+
// Preflight requests also include methods, headers and max-age
99+
if (null !== $request->header('Access-Control-Request-Method')) {
100+
$this->methods && $response->header('Access-Control-Allow-Methods', implode(', ', $this->methods));
101+
$this->headers && $response->header('Access-Control-Allow-Headers', implode(', ', $this->headers));
102+
$this->maxAge && $response->header('Access-Control-Max-Age', $this->maxAge);
103+
$response->answer(204);
104+
return;
105+
}
106+
}
107+
return $invocation->proceed($request, $response);
108+
}
109+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php namespace web\unittest\filters;
2+
3+
use test\{Assert, Test, Values};
4+
use web\filters\{CORS, 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+
/** Values for preflight test */
22+
private function preflights(): iterable {
23+
$cors= (new CORS()->origins(self::ORIGIN));
24+
yield [$cors, []];
25+
yield [(clone $cors)->origins(fn($origin) => self::ORIGIN === $origin ? $origin : null), []];
26+
yield [(clone $cors)->origins('*'), ['Access-Control-Allow-Origin' => '*']];
27+
28+
// Methods
29+
yield [(clone $cors)->methods(null), []];
30+
yield [(clone $cors)->methods([]), []];
31+
yield [(clone $cors)->methods('GET, POST'), ['Access-Control-Allow-Methods' => 'GET, POST']];
32+
yield [(clone $cors)->methods(['GET', 'POST']), ['Access-Control-Allow-Methods' => 'GET, POST']];
33+
34+
// Headers
35+
yield [(clone $cors)->headers(null), []];
36+
yield [(clone $cors)->headers([]), []];
37+
yield [(clone $cors)->headers('X-Input'), ['Access-Control-Allow-Headers' => 'X-Input']];
38+
yield [(clone $cors)->headers(['X-Input']), ['Access-Control-Allow-Headers' => 'X-Input']];
39+
40+
// Age
41+
yield [(clone $cors)->maxAge(null), []];
42+
yield [(clone $cors)->maxAge(0), []];
43+
yield [(clone $cors)->maxAge(86400), ['Access-Control-Max-Age' => '86400']];
44+
45+
// Expose
46+
yield [(clone $cors)->expose(null), []];
47+
yield [(clone $cors)->expose([]), []];
48+
yield [(clone $cors)->expose('X-Output'), ['Access-Control-Expose-Headers' => 'X-Output']];
49+
yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']];
50+
51+
// Credentials
52+
yield [(clone $cors)->credentials(false), []];
53+
yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']];
54+
}
55+
56+
/** Values for request test */
57+
private function requests(): iterable {
58+
$cors= (new CORS()->origins(self::ORIGIN));
59+
yield [$cors, []];
60+
61+
// Only included in preflight
62+
yield [(clone $cors)->methods(['GET', 'POST']), []];
63+
yield [(clone $cors)->headers(['X-Input']), []];
64+
yield [(clone $cors)->maxAge(86400), []];
65+
66+
// Included in all requests
67+
yield [(clone $cors)->expose(['X-Output']), ['Access-Control-Expose-Headers' => 'X-Output']];
68+
yield [(clone $cors)->credentials(true), ['Access-Control-Allow-Credentials' => 'true']];
69+
}
70+
71+
/** Values for allowing_origin_with_any_4_digit_port */
72+
private function origins(): iterable {
73+
yield [self::ORIGIN, true];
74+
yield [self::ORIGIN.':3000', true];
75+
76+
// Not allowed
77+
yield [self::ORIGIN.':443', false];
78+
yield [strtr(self::ORIGIN, ['http:' => 'https:']), false];
79+
yield ['http://localhost', false];
80+
yield ['', false];
81+
}
82+
83+
#[Test]
84+
public function can_create() {
85+
new CORS();
86+
}
87+
88+
#[Test]
89+
public function request_without_origin_receives_no_cors() {
90+
$response= $this->filter(new CORS(), 'GET', '/');
91+
92+
Assert::equals(200, $response->status());
93+
Assert::equals(self::RESPONSE, $response->headers());
94+
}
95+
96+
#[Test, Values(from: 'preflights')]
97+
public function preflight($fixture, $expected) {
98+
$response= $this->filter($fixture, 'OPTIONS', '/', [
99+
'Origin' => self::ORIGIN,
100+
'Access-Control-Request-Method' => 'GET',
101+
'Access-Control-Request-Headers' => 'X-Input',
102+
]);
103+
104+
Assert::equals(204, $response->status());
105+
Assert::equals(
106+
$expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN],
107+
$response->headers()
108+
);
109+
}
110+
111+
#[Test, Values(from: 'requests')]
112+
public function request($fixture, $expected) {
113+
$response= $this->filter($fixture, 'GET', '/', ['Origin' => self::ORIGIN]);
114+
115+
Assert::equals(200, $response->status());
116+
Assert::equals(
117+
$expected + ['Vary' => 'Origin', 'Access-Control-Allow-Origin' => self::ORIGIN, ...self::RESPONSE],
118+
$response->headers()
119+
);
120+
}
121+
122+
#[Test, Values(from: 'origins')]
123+
public function allowing_origin_with_any_4_digit_port($origin, $allow) {
124+
$fixture= (new CORS())->origins(function($origin) {
125+
return preg_match('/^'.preg_quote(self::ORIGIN, '/').'(:[0-9]{4})?$/', $origin) ? $origin : null;
126+
});
127+
$response= $this->filter($fixture, 'GET', '/', ['Origin' => $origin]);
128+
129+
Assert::equals(200, $response->status());
130+
Assert::equals(
131+
($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin', ...self::RESPONSE],
132+
$response->headers()
133+
);
134+
}
135+
}

0 commit comments

Comments
 (0)