From 90dfeb727acb718136ab146379abd001e874a12e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 9 Jan 2018 12:31:40 -0500 Subject: [PATCH] Implement CORS; fixes #126 --- lib/Conf.php | 5 ++ lib/REST.php | 111 ++++++++++++++++++++++++ tests/cases/REST/TestREST.php | 153 +++++++++++++++++++++++++++++++++- tests/lib/AbstractTest.php | 5 ++ 4 files changed, 273 insertions(+), 1 deletion(-) diff --git a/lib/Conf.php b/lib/Conf.php index 0fe10529..bd046ff9 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -72,6 +72,11 @@ class Conf { * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesUnread = "P21D"; + /** @var string Space-separated list of origins from which to allow cross-origin resource sharing */ + public $httpOriginsAllowed = "*"; + /** @var string Space-separated list of origins from which to deny cross-origin resource sharing */ + public $httpOriginsDenied = ""; + /** Creates a new configuration object * @param string $import_file Optional file to read configuration data from * @see self::importFile() */ diff --git a/lib/REST.php b/lib/REST.php index 865e450d..fc4cfacc 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -52,6 +52,10 @@ class REST { // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; + const DEFAULT_PORTS = [ + 'http' => 80, + 'https' => 443, + ]; protected $apis = []; public function __construct(array $apis = null) { @@ -143,6 +147,113 @@ class REST { } $res = $res->withHeader("Allow", implode(", ", $methods)); } + // add CORS header fields if the request origin is specified and allowed + if ($req && $this->corsNegotiate($req)) { + $res = $this->corsApply($res, $req); + } return $res; } + + public function corsApply(ResponseInterface $res, RequestInterface $req = null): ResponseInterface { + if ($req && $req->getMethod()=="OPTIONS") { + if ($res->hasHeader("Allow")) { + $res = $res->withHeader("Access-Control-Allow-Methods", $res->getHeaderLine("Allow")); + } + if ($req->hasHeader("Access-Control-Request-Headers")) { + $res = $res->withHeader("Access-Control-Allow-Headers", $req->getHeaderLine("Access-Control-Request-Headers")); + } + $res = $res->withHeader("Access-Control-Max-Age", (string) (60 *60 *24) ); // one day + } + $res = $res->withHeader("Access-Control-Allow-Origin", $req->getHeaderLine("Origin")); + $res = $res->withHeader("Access-Control-Allow-Credentials", "true"); + return $res->withAddedHeader("Vary", "Origin"); + } + + public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool { + $allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? ""); + $denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? ""); + // continue if at least one origin is allowed + if ($allowed) { + // continue if the request has exactly one Origin header + $origin = $req->getHeader("Origin"); + if (sizeof($origin)==1) { + // continue if the origin is syntactically valid + $origin = $this->corsNormalizeOrigin($origin[0]); + if ($origin) { + // the special "null" origin should not be matched by the wildcard origin + $null = ($origin=="null"); + // pad all strings for simpler comparison + $allowed = " ".$allowed." "; + $denied = " ".$denied." "; + $origin = " ".$origin." "; + $any = " * "; + if (strpos($denied, $origin) !== false) { + // first check the denied list for the origin + return false; + } elseif (strpos($allowed, $origin) !== false) { + // next check the allowed list for the origin + return true; + } elseif (!$null && strpos($denied, $any) !== false) { + // next check the denied list for the wildcard origin + return false; + } elseif (!$null && strpos($allowed, $any) !== false) { + // finally check the allowed list for the wildcard origin + return true; + } + } + } + } + return false; + } + + public function corsNormalizeOrigin(string $origin, array $ports = null): string { + $origin = trim($origin); + if ($origin=="null") { + // if the origin is the special value "null", use it + return "null"; + } + if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>i", $origin, $match)) { + // if the origin sort-of matches the syntax in a general sense, continue + $scheme = $match[1]; + $host = $match[2]; + $port = $match[3]; + // decode and normalize the scheme and port (the port may be blank) + $scheme = strtolower(rawurldecode($scheme)); + $port = rawurldecode($port); + if (!preg_match("<^(?::[0-9]+)?$>", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>", $scheme)) { + // if the normalized port contains anything but numbers, or the scheme does not follow the generic URL syntax, the origin is invalid + return ""; + } + if ($host[0]=="[") { + // if the host appears to be an IPv6 address, validate it + $host = rawurldecode(substr($host, 1, strlen($host) - 2)); + if (!filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return ""; + } else { + $host = "[".inet_ntop(inet_pton($host))."]"; + } + } else { + // if the host is a domain name or IP address, split it along dots and just perform URL decoding + $host = explode(".", $host); + $host = array_map(function ($segment) { + return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment)))); + }, $host); + $host = implode(".", $host); + } + // suppress default ports + if (strlen($port)) { + $port = (int) substr($port, 1); + $list = array_merge($ports ?? [], self::DEFAULT_PORTS); + if (isset($list[$scheme]) && $port==$list[$scheme]) { + $port = ""; + } else { + $port = ":".$port; + } + } + // return the reconstructed result + return $scheme."://".$host.$port; + } else { + return ""; + } + } } diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index 203565c4..ed67e44f 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -59,9 +59,157 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { ]; } + /** @dataProvider provideUnnormalizedOrigins */ + public function testNormalizeOrigins(string $origin, string $exp, array $ports = null) { + $r = new REST(); + $act = $r->corsNormalizeOrigin($origin, $ports); + $this->assertSame($exp, $act); + } + + public function provideUnnormalizedOrigins() { + return [ + ["null", "null"], + ["http://example.com", "http://example.com"], + ["http://example.com:80", "http://example.com"], + ["http://example.com:8%30", "http://example.com"], + ["http://example.com:8080", "http://example.com:8080"], + ["http://[2001:0db8:0:0:0:0:2:1]", "http://[2001:db8::2:1]"], + ["http://example", "http://example"], + ["http://ex%41mple", "http://example"], + ["http://ex%41mple.co.uk", "http://example.co.uk"], + ["http://ex%41mple.co%2euk", "http://example.co%2Euk"], + ["http://example/", ""], + ["http://example?", ""], + ["http://example#", ""], + ["http://user@example", ""], + ["http://user:pass@example", ""], + ["http://[example", ""], + ["http://[2bef]", ""], + ["http://example%2F", "http://example%2F"], + ["HTTP://example", "http://example"], + ["HTTP://EXAMPLE", "http://example"], + ["%48%54%54%50://example", "http://example"], + ["http:%2F%2Fexample", ""], + ["https://example", "https://example"], + ["https://example:443", "https://example"], + ["https://example:80", "https://example:80"], + ["ssh://example", "ssh://example"], + ["ssh://example:22", "ssh://example:22"], + ["ssh://example:22", "ssh://example", ['ssh' => 22]], + ["SSH://example:22", "ssh://example", ['ssh' => 22]], + ["ssh://example:22", "ssh://example", ['ssh' => "22"]], + ["ssh://example:22", "ssh://example:22", ['SSH' => "22"]], + ]; + } + + /** @dataProvider provideCorsNegotiations */ + public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null) { + $this->setConf(); + $r = Phake::partialMock(REST::class); + Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function ($origin) { + return $origin; + }); + $req = new Request("", "GET", "php://memory", ['Origin' => $origin]); + $act = $r->corsNegotiate($req, $allowed, $denied); + $this->assertSame($exp, $act); + } + + public function provideCorsNegotiations() { + return [ + ["http://example", true ], + ["http://example", true, "http://example", "*" ], + ["http://example", false, "http://example", "http://example"], + ["http://example", false, "https://example", "*" ], + ["http://example", false, "*", "*" ], + ["http://example", true, "*", "" ], + ["http://example", false, "", "" ], + ["null", false ], + ["null", true, "null", "*" ], + ["null", false, "null", "null" ], + ["null", false, "*", "*" ], + ["null", false, "*", "" ], + ["null", false, "", "" ], + ["", false ], + ["", false, "", "*" ], + ["", false, "", "" ], + ["", false, "*", "*" ], + ["", false, "*", "" ], + [["null", "http://example"], false, "*", "" ], + [[], false, "*", "" ], + ]; + } + + /** @dataProvider provideCorsHeaders */ + public function testAddCorsHeaders(string $reqMethod, array $reqHeaders, array $resHeaders, array $expHeaders) { + $r = new REST(); + $req = new Request("", $reqMethod, "php://memory", $reqHeaders); + $res = new EmptyResponse(204, $resHeaders); + $exp = new EmptyResponse(204, $expHeaders); + $act = $r->corsApply($res, $req); + $this->assertResponse($exp, $act); + } + + public function provideCorsHeaders() { + return [ + ["GET", ['Origin' => "null"], [], [ + 'Access-Control-Allow-Origin' => "null", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + ]], + ["GET", ['Origin' => "http://example"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + ]], + ["GET", ['Origin' => "http://example"], ['Content-Type' => "text/plain; charset=utf-8"], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + 'Content-Type' => "text/plain; charset=utf-8", + ]], + ["GET", ['Origin' => "http://example"], ['Vary' => "Content-Type"], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => ["Content-Type", "Origin"], + ]], + ["OPTIONS", ['Origin' => "http://example"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example"], ['Allow' => "GET, PUT, HEAD, OPTIONS"], [ + 'Allow' => "GET, PUT, HEAD, OPTIONS", + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Methods' => "GET, PUT, HEAD, OPTIONS", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => "Content-Type, If-None-Match"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Headers' => "Content-Type, If-None-Match", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => ["Content-Type", "If-None-Match"]], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Headers' => "Content-Type,If-None-Match", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ]; + } + /** @dataProvider provideUnnormalizedResponses */ public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) { - $r = new REST(); + $r = Phake::partialMock(REST::class); + Phake::when($r)->corsNegotiate->thenReturn(true); + Phake::when($r)->corsApply->thenReturnCallback(function ($res) { + return $res; + }); $act = $r->normalizeResponse($res, $req); $this->assertResponse($exp, $act); } @@ -98,6 +246,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideMockRequests */ public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") { $r = Phake::partialMock(REST::class); + Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) { + return $res; + }); if ($called) { $h = Phake::mock($class); Phake::when($r)->getHandler($class)->thenReturn($h); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 13b56413..cf3c7772 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; @@ -23,6 +24,10 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->clearData(); } + public function setConf(array $conf = []) { + Arsse::$conf = (new Conf)->import($conf); + } + public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;