Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
118 changes: 118 additions & 0 deletions src/main/php/web/filters/CORS.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php namespace web\filters;

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;
}

/**
* 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) {
$response->header('Vary', 'Origin');
$response->header('Access-Control-Allow-Origin', is_string($this->origins ?? '')
? $this->origins
: ($this->origins)($origin)
);

// 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);
}
}
88 changes: 88 additions & 0 deletions src/main/php/web/filters/Origins.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php namespace web\filters;

use lang\IllegalArgumentException;
use util\Objects;

/**
* Verifies origins
*
* @see https://github.com/xp-forge/web/pull/131#pullrequestreview-4444923370
* @test web.unittest.filters.OriginsTest
*/
class Origins {
public $bases;
public $ports= null;

public function __construct($bases) {
$this->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`: Directly match origins
* - `'*'`: 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;
}
}
136 changes: 136 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,136 @@
<?php namespace web\unittest\filters;

use test\{Assert, Test, Values};
use web\filters\{CORS, Origins, 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_plain_or_with_port_3000 */
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_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());
Assert::equals(
($allow ? ['Access-Control-Allow-Origin' => $origin] : []) + ['Vary' => 'Origin'] + self::RESPONSE,
$response->headers()
);
}
}
Loading