mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 13:12:41 +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 */
|
||||
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() */
|
||||
|
|
111
lib/REST.php
111
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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue