1
1
Fork 0
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:
J. King 2018-01-09 12:31:40 -05:00
parent 0ec0a5b085
commit 90dfeb727a
4 changed files with 273 additions and 1 deletions

View file

@ -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() */

View file

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

View file

@ -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);

View file

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