Skip to content
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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions _config/config.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
---
Name: graphqlconfig
---
# Minimum fields that any type will expose. Useful for implicitly
# created types, e.g. exposing a has_one.
SilverStripe\GraphQL\Scaffolding\Scaffolders\DataObjectScaffolder:
default_fields:
ID: ID

# Define the type parsers
SilverStripe\Core\Injector\Injector:
SilverStripe\GraphQL\Scaffolding\Interfaces\TypeParserInterface.string:
Expand All @@ -30,11 +24,3 @@ SilverStripe\ORM\FieldType\DBPrimaryKey:
SilverStripe\ORM\FieldType\DBForeignKey:
graphql_type: ID

## CORS default config
SilverStripe\GraphQL\Controller:
cors:
Enabled: false # Off by default
Allow-Origin: # Deny all by default
Allow-Headers: 'Authorization, Content-Type'
Allow-Methods: 'GET, POST, OPTIONS'
Max-Age: 86400 # 86,400 seconds = 1 day.
247 changes: 171 additions & 76 deletions src/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
*/
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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');

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"

Copy link
Contributor Author

@tractorcow tractorcow Jan 21, 2018

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.

Copy link
Contributor Author

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

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.

Copy link
Contributor Author

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.

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

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.

Copy link
Contributor Author

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

What part is broken?

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 :)

Copy link
Contributor

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

Remember: CORS is not security. Do not rely on CORS to secure your site. If you are serving protected data, use cookies or OAuth tokens or something other than the Origin header to secure that data. The Access-Control-Allow-Origin header in CORS only dictates which origins should be allowed to make cross-origin requests. Don't rely on it for anything more.

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']) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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)

Also adds a check for the pre-flight OPTIONS request, and if CORS is enabled, adds the appropriate headers to the response object - both for the OPTIONS response and for the actual data response.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same workflow; In which situation is it different?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I needed to double check this :)

I overlooked the second === 'OPTIONS' that was removed above

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
}
}
2 changes: 1 addition & 1 deletion src/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace SilverStripe\GraphQL;

use Doctrine\Instantiator\Exception\InvalidArgumentException;
use InvalidArgumentException;
use GraphQL\Executor\ExecutionResult;
use GraphQL\Language\SourceLocation;
use GraphQL\Schema;
Expand Down
Loading