mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 21:22:40 +00:00
Implement CORS; fixes #126
This commit is contained in:
parent
0ec0a5b085
commit
90dfeb727a
4 changed files with 273 additions and 1 deletions
|
@ -72,6 +72,11 @@ class Conf {
|
||||||
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
|
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
|
||||||
public $purgeArticlesUnread = "P21D";
|
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
|
/** Creates a new configuration object
|
||||||
* @param string $import_file Optional file to read configuration data from
|
* @param string $import_file Optional file to read configuration data from
|
||||||
* @see self::importFile() */
|
* @see self::importFile() */
|
||||||
|
|
111
lib/REST.php
111
lib/REST.php
|
@ -52,6 +52,10 @@ class REST {
|
||||||
// NewsBlur http://www.newsblur.com/api
|
// NewsBlur http://www.newsblur.com/api
|
||||||
// Feedly https://developer.feedly.com/
|
// Feedly https://developer.feedly.com/
|
||||||
];
|
];
|
||||||
|
const DEFAULT_PORTS = [
|
||||||
|
'http' => 80,
|
||||||
|
'https' => 443,
|
||||||
|
];
|
||||||
protected $apis = [];
|
protected $apis = [];
|
||||||
|
|
||||||
public function __construct(array $apis = null) {
|
public function __construct(array $apis = null) {
|
||||||
|
@ -143,6 +147,113 @@ class REST {
|
||||||
}
|
}
|
||||||
$res = $res->withHeader("Allow", implode(", ", $methods));
|
$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;
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */
|
/** @dataProvider provideUnnormalizedResponses */
|
||||||
public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) {
|
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);
|
$act = $r->normalizeResponse($res, $req);
|
||||||
$this->assertResponse($exp, $act);
|
$this->assertResponse($exp, $act);
|
||||||
}
|
}
|
||||||
|
@ -98,6 +246,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||||
/** @dataProvider provideMockRequests */
|
/** @dataProvider provideMockRequests */
|
||||||
public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") {
|
public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") {
|
||||||
$r = Phake::partialMock(REST::class);
|
$r = Phake::partialMock(REST::class);
|
||||||
|
Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) {
|
||||||
|
return $res;
|
||||||
|
});
|
||||||
if ($called) {
|
if ($called) {
|
||||||
$h = Phake::mock($class);
|
$h = Phake::mock($class);
|
||||||
Phake::when($r)->getHandler($class)->thenReturn($h);
|
Phake::when($r)->getHandler($class)->thenReturn($h);
|
||||||
|
|
|
@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\Test;
|
||||||
|
|
||||||
use JKingWeb\Arsse\Exception;
|
use JKingWeb\Arsse\Exception;
|
||||||
use JKingWeb\Arsse\Arsse;
|
use JKingWeb\Arsse\Arsse;
|
||||||
|
use JKingWeb\Arsse\Conf;
|
||||||
use JKingWeb\Arsse\Misc\Date;
|
use JKingWeb\Arsse\Misc\Date;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Zend\Diactoros\Response\JsonResponse;
|
use Zend\Diactoros\Response\JsonResponse;
|
||||||
|
@ -23,6 +24,10 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
|
||||||
$this->clearData();
|
$this->clearData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setConf(array $conf = []) {
|
||||||
|
Arsse::$conf = (new Conf)->import($conf);
|
||||||
|
}
|
||||||
|
|
||||||
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
|
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
|
||||||
if (func_num_args()) {
|
if (func_num_args()) {
|
||||||
$class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
|
$class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
|
||||||
|
|
Loading…
Reference in a new issue