-
Notifications
You must be signed in to change notification settings - Fork 61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
BUG Fix cors breaking if referer header is present #132
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,15 +2,16 @@ | |
|
||
namespace SilverStripe\GraphQL; | ||
|
||
use Exception; | ||
use SilverStripe\Control\Controller as BaseController; | ||
use SilverStripe\Control\Director; | ||
use SilverStripe\Control\HTTPRequest; | ||
use SilverStripe\Control\HTTPResponse; | ||
use SilverStripe\Core\Config\Config; | ||
use SilverStripe\Control\Director; | ||
use SilverStripe\GraphQL\Auth\Handler; | ||
use SilverStripe\Versioned\Versioned; | ||
use Exception; | ||
use SilverStripe\Security\Member; | ||
use SilverStripe\Security\Permission; | ||
use SilverStripe\Versioned\Versioned; | ||
|
||
/** | ||
* Top level controller for handling graphql requests. | ||
|
@@ -19,6 +20,20 @@ | |
*/ | ||
class Controller extends BaseController | ||
{ | ||
/** | ||
* Cors default config | ||
* | ||
* @config | ||
* @var array | ||
*/ | ||
private static $cors = [ | ||
'Enabled' => false, // Off by default | ||
'Allow-Origin' => [], // List of all allowed origins; Deny by default | ||
'Allow-Headers' => 'Authorization, Content-Type', | ||
'Allow-Methods' => 'GET, POST, OPTIONS', | ||
'Max-Age' => 86400, // 86,400 seconds = 1 day. | ||
]; | ||
|
||
/** | ||
* @var Manager | ||
*/ | ||
|
@@ -39,56 +54,22 @@ public function index(HTTPRequest $request) | |
|
||
// Check for a possible CORS preflight request and handle if necessary | ||
// Refer issue 66: https://github.com/silverstripe/silverstripe-graphql/issues/66 | ||
$corsConfig = Config::inst()->get(self::class, 'cors'); | ||
$corsEnabled = true; // Default to have CORS turned on. | ||
|
||
if ($corsConfig && isset($corsConfig['Enabled']) && !$corsConfig['Enabled']) { | ||
// Dev has turned off CORS | ||
$corsEnabled = false; | ||
} | ||
if ($corsEnabled && $request->httpMethod() == 'OPTIONS') { | ||
// CORS config is enabled and the request is an OPTIONS pre-flight. | ||
// Process the CORS config and add appropriate headers. | ||
$response = new HTTPResponse(); | ||
return $this->addCorsHeaders($request, $response); | ||
} elseif (!$corsEnabled && $request->httpMethod() == 'OPTIONS') { | ||
// CORS is disabled but we have received an OPTIONS request. This is not a valid request method in this | ||
// situation. Return a 405 Method Not Allowed response. | ||
return $this->httpError(405, "Method Not Allowed"); | ||
} | ||
|
||
$contentType = $request->getHeader('Content-Type') ?: $request->getHeader('content-type'); | ||
$isJson = preg_match('#^application/json\b#', $contentType); | ||
if ($isJson) { | ||
$rawBody = $request->getBody(); | ||
$data = json_decode($rawBody ?: '', true); | ||
$query = isset($data['query']) ? $data['query'] : null; | ||
$variables = isset($data['variables']) ? (array) $data['variables'] : null; | ||
} else { | ||
$query = $request->requestVar('query'); | ||
$variables = json_decode($request->requestVar('variables'), true); | ||
if ($request->httpMethod() === 'OPTIONS') { | ||
return $this->handleOptions($request); | ||
} | ||
|
||
$this->setManager($manager = $this->getManager()); | ||
|
||
// Main query handling | ||
try { | ||
// Check authentication | ||
$member = $this->getAuthHandler()->requireAuthentication($request); | ||
$manager = $this->getManager(); | ||
|
||
// Check and validate user for this request | ||
$member = $this->getRequestUser($request); | ||
if ($member) { | ||
$manager->setMember($member); | ||
} | ||
|
||
// Check authorisation | ||
$permissions = $request->param('Permissions'); | ||
if ($permissions) { | ||
if (!$member) { | ||
throw new Exception("Authentication required"); | ||
} | ||
$allowed = Permission::checkMember($member, $permissions); | ||
if (!$allowed) { | ||
throw new Exception("Not authorised"); | ||
} | ||
} | ||
// Parse input | ||
list($query, $variables) = $this->getRequestQueryVariables($request); | ||
|
||
// Run query | ||
$result = $manager->query($query, $variables); | ||
|
@@ -123,16 +104,18 @@ public function getManager() | |
// Get a service rather than an instance (to allow procedural configuration) | ||
$config = Config::inst()->get(static::class, 'schema'); | ||
$manager = Manager::createFromConfig($config); | ||
|
||
$this->setManager($manager); | ||
return $manager; | ||
} | ||
|
||
/** | ||
* @param Manager $manager | ||
* @return $this | ||
*/ | ||
public function setManager($manager) | ||
{ | ||
$this->manager = $manager; | ||
return $this; | ||
} | ||
|
||
/** | ||
|
@@ -155,45 +138,157 @@ public function getAuthHandler() | |
public function addCorsHeaders(HTTPRequest $request, HTTPResponse $response) | ||
{ | ||
$corsConfig = Config::inst()->get(static::class, 'cors'); | ||
|
||
// If CORS is disabled don't add the extra headers. Simply return the response untouched. | ||
if (empty($corsConfig['Enabled'])) { | ||
// If CORS is disabled don't add the extra headers. Simply return the response untouched. | ||
return $response; | ||
} | ||
|
||
// Allow Origins header. | ||
if (is_string($corsConfig['Allow-Origin'])) { | ||
$allowedOrigins = [$corsConfig['Allow-Origin']]; | ||
} else { | ||
$allowedOrigins = $corsConfig['Allow-Origin']; | ||
} | ||
if (!empty($allowedOrigins)) { | ||
$origin = $request->getHeader('Origin'); | ||
if ($origin) { | ||
$originAuthorised = false; | ||
foreach ($allowedOrigins as $allowedOrigin) { | ||
if ($allowedOrigin == $origin || $allowedOrigin === '*') { | ||
$response->addHeader("Access-Control-Allow-Origin", $origin); | ||
$originAuthorised = true; | ||
break; | ||
} | ||
} | ||
|
||
if (!$originAuthorised) { | ||
return $this->httpError(403, "Access Forbidden"); | ||
} | ||
} else { | ||
// No Origin header present in Request. | ||
return $this->httpError(403, "Access Forbidden"); | ||
} | ||
} else { | ||
// No allowed origins, ergo all origins forbidden. | ||
return $this->httpError(403, "Access Forbidden"); | ||
// Calculate origin | ||
$origin = $this->getRequestOrigin($request); | ||
|
||
// Check if valid | ||
$allowedOrigins = (array)$corsConfig['Allow-Origin']; | ||
$originAuthorised = $this->validateOrigin($origin, $allowedOrigins); | ||
if (!$originAuthorised) { | ||
$this->httpError(403, "Access Forbidden"); | ||
} | ||
|
||
$response->addHeader('Access-Control-Allow-Origin', $origin); | ||
$response->addHeader('Access-Control-Allow-Headers', $corsConfig['Allow-Headers']); | ||
$response->addHeader('Access-Control-Allow-Methods', $corsConfig['Allow-Methods']); | ||
$response->addHeader('Access-Control-Max-Age', $corsConfig['Max-Age']); | ||
|
||
return $response; | ||
} | ||
|
||
/** | ||
* Validate an origin matches a set of allowed origins | ||
* | ||
* @param string $origin Origin string | ||
* @param array $allowedOrigins List of allowed origins | ||
* @return bool | ||
*/ | ||
protected function validateOrigin($origin, $allowedOrigins) | ||
{ | ||
if (empty($allowedOrigins) || empty($origin)) { | ||
return false; | ||
} | ||
foreach ($allowedOrigins as $allowedOrigin) { | ||
if ($allowedOrigin === '*') { | ||
return true; | ||
} | ||
if (strcasecmp($allowedOrigin, $origin) === 0) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
/** | ||
* Get (or infer) value of Origin header | ||
* | ||
* @param HTTPRequest $request | ||
* @return string|null | ||
*/ | ||
protected function getRequestOrigin(HTTPRequest $request) | ||
{ | ||
// Prefer Origin header | ||
$origin = $request->getHeader('Origin'); | ||
if ($origin) { | ||
return $origin; | ||
} | ||
|
||
// Check referer | ||
$referer = $request->getHeader('Referer'); | ||
if ($referer) { | ||
// Extract protocol, hostname, and port | ||
$refererParts = parse_url($referer); | ||
if (!$refererParts) { | ||
return null; | ||
} | ||
// Rebuild | ||
$origin = $refererParts['scheme'] . '://' . $refererParts['host']; | ||
if (isset($refererParts['port'])) { | ||
$origin .= ':' . $refererParts['port']; | ||
} | ||
return $origin; | ||
} | ||
|
||
return null; | ||
} | ||
|
||
/** | ||
* Response for HTTP OPTIONS request | ||
* | ||
* @param HTTPRequest $request | ||
* @return HTTPResponse | ||
*/ | ||
protected function handleOptions(HTTPRequest $request) | ||
{ | ||
$response = HTTPResponse::create(); | ||
$corsConfig = Config::inst()->get(self::class, 'cors'); | ||
if ($corsConfig['Enabled']) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this changes the workflow of the CORS headers... from the original ticket which raised this concern #66 (comment)
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's the same workflow; In which situation is it different? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, I needed to double check this :) I overlooked the second There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah the original code was hard to follow, hence the refactor. :) |
||
// CORS config is enabled and the request is an OPTIONS pre-flight. | ||
// Process the CORS config and add appropriate headers. | ||
$this->addCorsHeaders($request, $response); | ||
} else { | ||
// CORS is disabled but we have received an OPTIONS request. This is not a valid request method in this | ||
// situation. Return a 405 Method Not Allowed response. | ||
$this->httpError(405, "Method Not Allowed"); | ||
} | ||
return $response; | ||
} | ||
|
||
/** | ||
* Parse query and variables from the given request | ||
* | ||
* @param HTTPRequest $request | ||
* @return array Array containing query and variables as a pair | ||
*/ | ||
protected function getRequestQueryVariables(HTTPRequest $request) | ||
{ | ||
$contentType = $request->getHeader('content-type'); | ||
$isJson = preg_match('#^application/json\b#', $contentType); | ||
if ($isJson) { | ||
$rawBody = $request->getBody(); | ||
$data = json_decode($rawBody ?: '', true); | ||
$query = isset($data['query']) ? $data['query'] : null; | ||
$variables = isset($data['variables']) ? (array)$data['variables'] : null; | ||
} else { | ||
$query = $request->requestVar('query'); | ||
$variables = json_decode($request->requestVar('variables'), true); | ||
} | ||
return [$query, $variables]; | ||
} | ||
|
||
/** | ||
* Get user and validate for this request | ||
* | ||
* @param HTTPRequest $request | ||
* @return Member | ||
*/ | ||
protected function getRequestUser(HTTPRequest $request) | ||
{ | ||
// Check authentication | ||
$member = $this->getAuthHandler()->requireAuthentication($request); | ||
|
||
// Check authorisation | ||
$permissions = $request->param('Permissions'); | ||
if (!$permissions) { | ||
return $member; | ||
} | ||
|
||
// If permissions requested require authentication | ||
if (!$member) { | ||
throw new Exception("Authentication required"); | ||
} | ||
|
||
// Check authorisation for this member | ||
$allowed = Permission::checkMember($member, $permissions); | ||
if (!$allowed) { | ||
throw new Exception("Not authorised"); | ||
} | ||
return $member; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would say this should be a IE/Edge only fallback, not a "fallback because whenever"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would it be? Firefox also has this issue in some conditions, and none of the other solutions I've seen around the web target IE.
E.g. juxt/yada#195
Referer isn't less trusted than Origin.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, user-agent targeting is bad practice. Read up https://webaim.org/blog/user-agent-string-history/ :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm well aware user-agent targeting is bad :)
I wasn't aware that Firefox showed the same behaviour sometimes. It makes sense to fully fall back in that case. it just feels a bit like a security issue, because it opens up the whole reason the CORS header was added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, older firefox wouldn't send origin on HTTP POST. My feeling is that if we errored on missing Origin when we had a valid Referer would be a bug on our part.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough, as the change is a "hack" that breaks a part of security anyway. It just makes me uncomfortable ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I stand with that it is a potential security issue, but also understand it's the only feasible solution at this moment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What part is broken?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically, the way CORS is supposed to work. I'm not saying the code itself is broken :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The origin header is just harder to spoof on browsers than the referrer header, while neither are enforced when using something like
curl
https://stackoverflow.com/q/21058183/695842
Two of the top answers resonate the same thing: don't rely on CORS for security