From e13c88e18e36f93f37c03d6b34314ea7ccaabd21 Mon Sep 17 00:00:00 2001 From: shadlaws Date: Sat, 15 Jun 2013 10:25:29 +0200 Subject: [PATCH] Add OPTIONS responses for CORS preflight requests. This required moving around REST auth a bit since OPTIONS doesn't need auth. @todo: add the admin screen to set the parameters --- modules/rest/classes/Rest/Controller/Rest.php | 81 ++++++++++++++----- .../Rest/Controller/Rest/AccessKey.php | 6 +- modules/rest/classes/Rest/Rest.php | 52 +++++++++++- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/modules/rest/classes/Rest/Controller/Rest.php b/modules/rest/classes/Rest/Controller/Rest.php index 349b46cbf9..654826073f 100644 --- a/modules/rest/classes/Rest/Controller/Rest.php +++ b/modules/rest/classes/Rest/Controller/Rest.php @@ -47,32 +47,16 @@ abstract class Rest_Controller_Rest extends Controller { ); /** - * Get the REST access key (if provided), attempt to login the user, and check auth. - * The only two possible results are a successful login or a 403 Forbidden. Because - * of this, the $auth variable is simply passed through without modification. + * Override Controller::check_auth() since REST doesn't have pages for login or reauth redirects. + * We check maintenance mode here, and handle REST authentication in Controller_Rest::before(). * * NOTE: this doesn't extend Controller::check_auth(), but rather *replaces* it with * its restful counterpart (i.e. parent::check_auth() is never called). * * @see Controller::check_auth(), which is replaced by this implementation - * @see Controller::auth_for_private_gallery() - * @see Controller::auth_for_maintenance_mode() - * @see Rest::set_active_user() */ public function check_auth($auth) { - // Get the access key (if provided) - $key = $this->request->headers("X-Gallery-Request-Key"); - if (empty($key)) { - $key = ($this->request->method() == HTTP_Request::GET) ? - $this->request->query("access_key") : $this->request->post("access_key"); - } - - // Attempt to login the user. This will fire a 403 Forbidden if unsuccessful. - Rest::set_active_user($key); - - // Check for maintenance mode or private gallery restrictions. Since there is no - // redirection to login/reauthenticate screen in REST, fire a 403 Forbidden if found. - if ($this->auth_for_maintenance_mode() || $this->auth_for_private_gallery()) { + if (Module::get_var("gallery", "maintenance_mode", 0)) { throw Rest_Exception::factory(403); } @@ -94,7 +78,7 @@ public function before() { $method = $this->request->method(); } - // If the method is not one of GET, POST, PUT, or DELETE, fire a 405 Method Not Allowed. + // If the method is not allowed, fire a 405 Method Not Allowed. if (!in_array($method, Rest::$allowed_methods)) { throw Rest_Exception::factory(405); } @@ -102,6 +86,28 @@ public function before() { // Set the action as the method. $this->request->action(strtolower($method)); + // If we have an OPTIONS request, we're done here. This intentionally skips login. + if ($method == HTTP_Request::OPTIONS) { + return; + } + + // Get the access key (if provided) + $key = $this->request->headers("X-Gallery-Request-Key"); + if (empty($key)) { + $key = ($this->request->method() == HTTP_Request::GET) ? + $this->request->query("access_key") : $this->request->post("access_key"); + } + + // Attempt to login the user. This will fire a 403 Forbidden if unsuccessful. + Rest::set_active_user($key); + + // Process the "Origin" header if sent (not required). + if (($method != HTTP_Request::OPTIONS) && + ($origin = $this->request->headers("Origin")) && + Rest::approve_origin($origin)) { + $this->response->headers("Access-Control-Allow-Origin", $origin); + } + // Get the REST type and id (note: strlen("Controller_Rest_") --> 16). $this->rest_type = Inflector::convert_class_to_module_name(substr(get_class($this), 16)); $this->rest_id = $this->request->arg_optional(0); @@ -341,6 +347,41 @@ public function action_delete() { Rest::delete($this->rest_type, $this->rest_id, $this->request->post()); } + /** + * Send an OPTIONS response for a CORS preflight request. This action should *not* + * ever be overriden in REST resource classes. + * @see http://www.w3.org/TR/cors + */ + public function action_options() { + $origin = $this->request->headers("Origin"); // required + $method = $this->request->headers("Access-Control-Request-Method"); // required + $headers = $this->request->headers("Access-Control-Request-Headers"); // optional + + $allow_origin = Rest::approve_origin($origin); + $allow_method = (!$method || in_array(strtoupper($method), Rest::$allowed_methods)); + $allow_headers = true; + if (!empty($headers)) { + $allowed_headers = array_map("strtolower", Rest::$allowed_headers); + $headers = explode(",", $headers); + foreach ($headers as $header) { + if (!in_array(strtolower(trim($header)), $allowed_headers)) { + $allow_headers = false; + } + } + } + + if (!$allow_origin || !$allow_method || !$allow_headers) { + throw Rest_Exception::factory(403); + } + + // CORS preflight passed - send response (headers only, no body). + $this->response->headers("Access-Control-Allow-Origin", $allow_origin); + $this->response->headers("Access-Control-Allow-Methods", Rest::$allowed_methods); + $this->response->headers("Access-Control-Allow-Headers", Rest::$allowed_headers); + $this->response->headers("Access-Control-Expose-Headers", Rest::$exposed_headers); + $this->response->headers("Access-Control-Max-Age", Rest::$preflight_max_age); + } + /** * Check if a method is defined for this resource. This is called by the standard * implementations of action_get(), action_post(), etc., and fires a 400 Bad Request diff --git a/modules/rest/classes/Rest/Controller/Rest/AccessKey.php b/modules/rest/classes/Rest/Controller/Rest/AccessKey.php index b85576ca4a..3b7043830c 100644 --- a/modules/rest/classes/Rest/Controller/Rest/AccessKey.php +++ b/modules/rest/classes/Rest/Controller/Rest/AccessKey.php @@ -18,7 +18,7 @@ * Foundation, Inc., 51 Franklin Street - Fifth Floor, Boston, MA 02110-1301, USA. */ class Rest_Controller_Rest_AccessKey extends Controller_Rest { - public function check_auth($auth) { + public function before() { if ($this->request->method() != HTTP_Request::GET) { // Check login using "user" and "password" fields in POST. Fire a 403 Forbidden if it fails. if (!Validation::factory($this->request->post()) @@ -31,12 +31,12 @@ public function check_auth($auth) { $this->request->headers("X-Gallery-Request-Key", Rest::access_key()); } - return parent::check_auth($auth); + return parent::before(); } public function action_get() { // We want to return an empty response with either status 200 or 403, depending on if guest - // access is allowed. Since Controller_Rest::check_auth() would have already fired a 403 + // access is allowed. Since Controller_Rest::before() would have already fired a 403 // if a login was required, we have nothing left to do here - this will return a 200. } diff --git a/modules/rest/classes/Rest/Rest.php b/modules/rest/classes/Rest/Rest.php index eee48682db..fc5dd578ee 100644 --- a/modules/rest/classes/Rest/Rest.php +++ b/modules/rest/classes/Rest/Rest.php @@ -24,9 +24,23 @@ class Rest_Rest { HTTP_Request::GET, HTTP_Request::POST, HTTP_Request::PUT, - HTTP_Request::DELETE + HTTP_Request::DELETE, + HTTP_Request::OPTIONS ); + static $allowed_headers = array( + "X-Gallery-Request-Key", + "X-Gallery-Request-Method", + "X-Requested-With" + ); + + static $exposed_headers = array( + "X-Gallery-Api-Version", + "Allow" + ); + + static $preflight_max_age = 604800; // one week + static function init() { // Add the REST API version and allowed methods to the header. Since we're adding it to // Response::$default_config, even error responses (e.g. 404) will have these headers. @@ -340,6 +354,42 @@ static function registry($return_class_names=false) { return array_unique($results); } + /** + * Approve an "Origin" header value. This checks the REST config and returns an + * "Access-Control-Allow-Origin" header value if it passes or false if it fails. + * + * @param string origin + * @return mixed false if failed, string if passed + * @see http://www.w3.org/TR/cors + */ + static function approve_origin($origin) { + $origin = strtolower($origin); + if (empty($origin)) { + return false; + } + + switch (Module::get_var("rest", "cors_embedding", "none")) { + case "all": + return "*"; + + case "list": + foreach (unserialize(Module::get_var("rest", "approved_domains", array())) as $domain) { + // Check the end of the sent origin against our list. So, if "example.com" is approved, + // then "foo.example.com", "http://example.com", and "https://foo.example.com" are also + // approved. + if (substr($origin, -strlen($domain)) == $domain) { + return $origin; + } + } + + case "none": + default: + // No checks if "none" or invalid. + } + + return false; + } + static protected function _call_rest_func($func, $type, $id, $params) { if (is_array($type)) { list ($type, $id, $params) = static::_split_triad($type);