Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/main/php/web/filters/CORS.class.php
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);
}
}
138 changes: 138 additions & 0 deletions src/test/php/web/unittest/filters/CORSTest.class.php
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;
Comment thread
thekid marked this conversation as resolved.
Outdated
});
$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()
);
}
}
Loading