From 62e1e2fcfef13a8b3b95e3ea5572f10a676df4ee Mon Sep 17 00:00:00 2001 From: gerchek Date: Fri, 18 Mar 2022 22:49:01 +0500 Subject: [PATCH] added cors plugin --- plugins/offline/cors/.gitignore | 1 + plugins/offline/cors/CONTRIBUTING.md | 7 + plugins/offline/cors/LICENSE | 21 ++ plugins/offline/cors/Plugin.php | 49 +++++ plugins/offline/cors/README.md | 45 ++++ plugins/offline/cors/classes/Cors.php | 71 +++++++ plugins/offline/cors/classes/CorsService.php | 199 ++++++++++++++++++ plugins/offline/cors/classes/HandleCors.php | 49 +++++ .../offline/cors/classes/HandlePreflight.php | 74 +++++++ .../offline/cors/classes/OriginMatcher.php | 100 +++++++++ .../offline/cors/classes/ServiceProvider.php | 90 ++++++++ plugins/offline/cors/composer.json | 13 ++ plugins/offline/cors/lang/de/lang.php | 6 + plugins/offline/cors/lang/en/lang.php | 6 + plugins/offline/cors/models/Settings.php | 12 ++ .../offline/cors/models/settings/fields.yaml | 55 +++++ plugins/offline/cors/plugin.yaml | 6 + plugins/offline/cors/updates/version.yaml | 14 ++ 18 files changed, 818 insertions(+) create mode 100644 plugins/offline/cors/.gitignore create mode 100644 plugins/offline/cors/CONTRIBUTING.md create mode 100644 plugins/offline/cors/LICENSE create mode 100644 plugins/offline/cors/Plugin.php create mode 100644 plugins/offline/cors/README.md create mode 100644 plugins/offline/cors/classes/Cors.php create mode 100644 plugins/offline/cors/classes/CorsService.php create mode 100644 plugins/offline/cors/classes/HandleCors.php create mode 100644 plugins/offline/cors/classes/HandlePreflight.php create mode 100644 plugins/offline/cors/classes/OriginMatcher.php create mode 100644 plugins/offline/cors/classes/ServiceProvider.php create mode 100644 plugins/offline/cors/composer.json create mode 100644 plugins/offline/cors/lang/de/lang.php create mode 100644 plugins/offline/cors/lang/en/lang.php create mode 100644 plugins/offline/cors/models/Settings.php create mode 100644 plugins/offline/cors/models/settings/fields.yaml create mode 100644 plugins/offline/cors/plugin.yaml create mode 100644 plugins/offline/cors/updates/version.yaml diff --git a/plugins/offline/cors/.gitignore b/plugins/offline/cors/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/plugins/offline/cors/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/plugins/offline/cors/CONTRIBUTING.md b/plugins/offline/cors/CONTRIBUTING.md new file mode 100644 index 0000000..f74dc2f --- /dev/null +++ b/plugins/offline/cors/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# How to contribute + +Contributions to this project are highly welcome. + +1. Submit your pull requests to the `develop` branch +1. Adhere to the [PSR-2 coding](http://www.php-fig.org/psr/psr-2/) standard +1. If you are not sure if your ideas are fit for this project, create an issue and ask \ No newline at end of file diff --git a/plugins/offline/cors/LICENSE b/plugins/offline/cors/LICENSE new file mode 100644 index 0000000..6aece30 --- /dev/null +++ b/plugins/offline/cors/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 OFFLINE GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/offline/cors/Plugin.php b/plugins/offline/cors/Plugin.php new file mode 100644 index 0000000..0dcb0e2 --- /dev/null +++ b/plugins/offline/cors/Plugin.php @@ -0,0 +1,49 @@ +app['Illuminate\Contracts\Http\Kernel'] + ->prependMiddleware(HandleCors::class); + + if (request()->isMethod('OPTIONS')) { + $this->app['Illuminate\Contracts\Http\Kernel'] + ->prependMiddleware(HandlePreflight::class); + } + } + + public function registerPermissions() + { + return [ + 'offline.cors.manage' => [ + 'label' => 'Can manage cors settings', + 'tab' => 'CORS', + ], + ]; + } + + public function registerSettings() + { + return [ + 'cors' => [ + 'label' => 'CORS-Settings', + 'description' => 'Manage CORS headers', + 'category' => 'system::lang.system.categories.cms', + 'icon' => 'icon-code', + 'class' => Settings::class, + 'order' => 500, + 'keywords' => 'cors', + 'permissions' => ['offline.cors.manage'], + ], + ]; + } +} diff --git a/plugins/offline/cors/README.md b/plugins/offline/cors/README.md new file mode 100644 index 0000000..ecbb035 --- /dev/null +++ b/plugins/offline/cors/README.md @@ -0,0 +1,45 @@ +# CORS plugin for October CMS + +This plugin is based on [https://github.com/barryvdh/laravel-cors](https://github.com/barryvdh/laravel-cors/blob/master/config/cors.php). + +All configuration for the plugin can be done via the backend settings. + +The following cors headers are supported: + +* Access-Control-Allow-Origin +* Access-Control-Allow-Headers +* Access-Control-Allow-Methods +* Access-Control-Allow-Credentials +* Access-Control-Expose-Headers +* Access-Control-Max-Age + +Currently these headers are sent for every request. There is no per-route configuration possible at this time. + +## Setup + +After installing the plugin visit the CORS settings page in your October CMS backend settings. + +You can add `*` as an entry to `Allowed origins`, `Allowed headers` and `Allowed methods` to allow any kind of CORS request from everywhere. + +It is advised to be more explicit about these settings. You can add values for each header via the repeater fields. + +> It is important to set these intial settings once for the plugin to work as excpected! + +### Filesystem configuration + +As an alternative to the backend settings you can create a `config/config.php` file in the plugins root directory to configure it. + +The filesystem configuration will overwrite any defined backend setting. + +```php + true, + 'maxAge' => 3600, + 'allowedOrigins' => ['*'], + 'allowedHeaders' => ['*'], + 'allowedMethods' => ['GET', 'POST'], + 'exposedHeaders' => [''], +]; +``` \ No newline at end of file diff --git a/plugins/offline/cors/classes/Cors.php b/plugins/offline/cors/classes/Cors.php new file mode 100644 index 0000000..2ceeade --- /dev/null +++ b/plugins/offline/cors/classes/Cors.php @@ -0,0 +1,71 @@ + [], + 'allowedMethods' => [], + 'allowedOrigins' => [], + 'exposedHeaders' => false, + 'maxAge' => false, + 'supportsCredentials' => false, + ]; + + /** + * Cors constructor. + * + * @param HttpKernelInterface $app + * @param array $options + */ + public function __construct(HttpKernelInterface $app, array $options = []) + { + $this->app = $app; + $this->cors = new CorsService(array_merge($this->defaultOptions, $options)); + + } + + /** + * @param Request $request + * @param int $type + * @param bool $catch + * + * @return bool|Response + */ + public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) + { + if ( ! $this->cors->isCorsRequest($request)) { + return $this->app->handle($request, $type, $catch); + } + + if ($this->cors->isPreflightRequest($request)) { + return $this->cors->handlePreflightRequest($request); + } + + if ( ! $this->cors->isActualRequestAllowed($request)) { + return new Response('Not allowed.', 403); + } + + $response = $this->app->handle($request, $type, $catch); + + return $this->cors->addActualRequestHeaders($response, $request); + } +} \ No newline at end of file diff --git a/plugins/offline/cors/classes/CorsService.php b/plugins/offline/cors/classes/CorsService.php new file mode 100644 index 0000000..41758e7 --- /dev/null +++ b/plugins/offline/cors/classes/CorsService.php @@ -0,0 +1,199 @@ +options = $this->normalizeOptions($options); + } + + private function normalizeOptions(array $options = []) + { + $options += [ + 'supportsCredentials' => false, + 'maxAge' => 0, + ]; + + // Make sure these values are arrays, if not specified in the backend settings. + $arrayKeys = [ + 'allowedOrigins', + 'allowedHeaders', + 'exposedHeaders', + 'allowedMethods', + ]; + + foreach ($arrayKeys as $key) { + if (!$options[$key]) { + $options[$key] = []; + } + } + + // normalize array('*') to true + if (in_array('*', $options['allowedOrigins'])) { + $options['allowedOrigins'] = true; + } + if (in_array('*', $options['allowedHeaders'])) { + $options['allowedHeaders'] = true; + } else { + $options['allowedHeaders'] = array_map('strtolower', $options['allowedHeaders']); + } + + if (in_array('*', $options['allowedMethods'])) { + $options['allowedMethods'] = true; + } else { + $options['allowedMethods'] = array_map('strtoupper', $options['allowedMethods']); + } + + return $options; + } + + public function isActualRequestAllowed(Request $request) + { + return $this->checkOrigin($request); + } + + public function isCorsRequest(Request $request) + { + return $request->headers->has('Origin') && $request->headers->get('Origin') !== $request->getSchemeAndHttpHost(); + } + + public function isPreflightRequest(Request $request) + { + return $this->isCorsRequest($request) + && $request->getMethod() === 'OPTIONS' + && $request->headers->has('Access-Control-Request-Method'); + } + + public function addActualRequestHeaders(Response $response, Request $request) + { + if ( ! $this->checkOrigin($request)) { + return $response; + } + + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + + if ( ! $response->headers->has('Vary')) { + $response->headers->set('Vary', 'Origin'); + } else { + $response->headers->set('Vary', $response->headers->get('Vary') . ', Origin'); + } + + if ($this->options['supportsCredentials']) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + if ($this->options['exposedHeaders']) { + $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->options['exposedHeaders'])); + } + + return $response; + } + + public function handlePreflightRequest(Request $request) + { + if (true !== $check = $this->checkPreflightRequestConditions($request)) { + return $check; + } + + return $this->buildPreflightCheckResponse($request); + } + + private function buildPreflightCheckResponse(Request $request) + { + $response = new Response(); + + if ($this->options['supportsCredentials']) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + + if ($this->options['maxAge']) { + $response->headers->set('Access-Control-Max-Age', $this->options['maxAge']); + } + + $allowMethods = $this->options['allowedMethods'] === true + ? strtoupper($request->headers->get('Access-Control-Request-Method')) + : implode(', ', $this->options['allowedMethods']); + $response->headers->set('Access-Control-Allow-Methods', $allowMethods); + + $allowHeaders = $this->options['allowedHeaders'] === true + ? strtoupper($request->headers->get('Access-Control-Request-Headers')) + : implode(', ', $this->options['allowedHeaders']); + $response->headers->set('Access-Control-Allow-Headers', $allowHeaders); + + return $response; + } + + private function checkPreflightRequestConditions(Request $request) + { + if ( ! $this->checkOrigin($request)) { + return $this->createBadRequestResponse(403, 'Origin not allowed'); + } + + if ( ! $this->checkMethod($request)) { + return $this->createBadRequestResponse(405, 'Method not allowed'); + } + + $requestHeaders = []; + // if allowedHeaders has been set to true ('*' allow all flag) just skip this check + if ($this->options['allowedHeaders'] !== true && $request->headers->has('Access-Control-Request-Headers')) { + $headers = strtolower($request->headers->get('Access-Control-Request-Headers')); + $requestHeaders = explode(',', $headers); + + foreach ($requestHeaders as $header) { + if ( ! in_array(trim($header), $this->options['allowedHeaders'])) { + return $this->createBadRequestResponse(403, 'Header not allowed'); + } + } + } + + return true; + } + + private function createBadRequestResponse($code, $reason = '') + { + return new Response($reason, $code); + } + + private function checkOrigin(Request $request) + { + if ($this->options['allowedOrigins'] === true) { + // allow all '*' flag + return true; + } + $origin = $request->headers->get('Origin'); + + foreach ($this->options['allowedOrigins'] as $allowedOrigin) { + if (OriginMatcher::matches($allowedOrigin, $origin)) { + return true; + } + } + + return false; + } + + private function checkMethod(Request $request) + { + if ($this->options['allowedMethods'] === true) { + // allow all '*' flag + return true; + } + + $requestMethod = strtoupper($request->headers->get('Access-Control-Request-Method')); + + return in_array($requestMethod, $this->options['allowedMethods']); + } + +} \ No newline at end of file diff --git a/plugins/offline/cors/classes/HandleCors.php b/plugins/offline/cors/classes/HandleCors.php new file mode 100644 index 0000000..9f2a88d --- /dev/null +++ b/plugins/offline/cors/classes/HandleCors.php @@ -0,0 +1,49 @@ +cors = $cors; + } + + /** + * Handle an incoming request. Based on Asm89\Stack\Cors by asm89 + * @see https://github.com/asm89/stack-cors/blob/master/src/Asm89/Stack/Cors.php + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @return mixed + */ + public function handle($request, Closure $next) + { + if ( ! $this->cors->isCorsRequest($request)) { + return $next($request); + } + + if ( ! $this->cors->isActualRequestAllowed($request)) { + abort(403); + } + + /** @var \Illuminate\Http\Response $response */ + $response = $next($request); + + return $this->cors->addActualRequestHeaders($response, $request); + } + +} \ No newline at end of file diff --git a/plugins/offline/cors/classes/HandlePreflight.php b/plugins/offline/cors/classes/HandlePreflight.php new file mode 100644 index 0000000..392b5a3 --- /dev/null +++ b/plugins/offline/cors/classes/HandlePreflight.php @@ -0,0 +1,74 @@ +cors = $cors; + $this->router = $router; + $this->kernel = $kernel; + } + + /** + * Handle an incoming OPTIONS request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @return mixed + */ + public function handle($request, Closure $next) + { + $response = $next($request); + if ($this->cors->isPreflightRequest($request) && $this->hasMatchingCorsRoute($request)) { + $preflight = $this->cors->handlePreflightRequest($request); + $response->headers->add($preflight->headers->all()); + } + $response->setStatusCode(204); + return $response; + } + + /** + * Verify the current OPTIONS request matches a CORS-enabled route + * + * @param \Illuminate\Http\Request $request + * + * @return boolean + */ + private function hasMatchingCorsRoute($request) + { + // Check if CORS is added in a global middleware + if ($this->kernel->hasMiddleware(HandleCors::class)) { + return true; + } + + // Check if CORS is added as a route middleware + $request = clone $request; + $request->setMethod($request->header('Access-Control-Request-Method')); + + try { + $route = $this->router->getRoutes()->match($request); + // change of method name in laravel 5.3 + if (method_exists($this->router, 'gatherRouteMiddleware')) { + $middleware = $this->router->gatherRouteMiddleware($route); + } else { + $middleware = $this->router->gatherRouteMiddlewares($route); + } + + return in_array(HandleCors::class, $middleware); + } catch (\Exception $e) { + app('log')->error($e); + + return false; + } + } +} diff --git a/plugins/offline/cors/classes/OriginMatcher.php b/plugins/offline/cors/classes/OriginMatcher.php new file mode 100644 index 0000000..bf529d2 --- /dev/null +++ b/plugins/offline/cors/classes/OriginMatcher.php @@ -0,0 +1,100 @@ + $patternComponent) { + if ($patternComponent === '*') { + return true; + } + if ( ! isset($hostComponents[$index])) { + return false; + } + if ($hostComponents[$index] !== $patternComponent) { + return false; + } + } + + return count($patternComponents) === count($hostComponents); + } + + public static function portMatches($pattern, $port) + { + if ($pattern === "*") { + return true; + } + if ((string)$pattern === "") { + return (string)$port === ""; + } + if (preg_match('/\A\d+\z/', $pattern)) { + return (string)$pattern === (string)$port; + } + if (preg_match('/\A(?P\d+)-(?P\d+)\z/', $pattern, $captured)) { + return $captured['from'] <= $port && $port <= $captured['to']; + } + throw new \InvalidArgumentException("Invalid port pattern: ${pattern}"); + } + + public static function parseOriginPattern($originPattern, $component = -1) + { + $matched = preg_match( + '!\A + (?: (?P ([a-z][a-z0-9+\-.]*) ):// )? + (?P (?:\*|[\w-]+)(?:\.[\w-]+)* ) + (?: :(?P (?: \*|\d+(?:-\d+)? ) ) )? + \z!x', + $originPattern, + $captured + ); + if ( ! $matched) { + throw new \InvalidArgumentException("Invalid origin pattern ${originPattern}"); + } + $components = [ + 'scheme' => $captured['scheme'] ?: null, + 'host' => $captured['host'], + 'port' => array_key_exists('port', $captured) ? $captured['port'] : null, + ]; + switch ($component) { + case -1: + return $components; + case PHP_URL_SCHEME: + return $components['scheme']; + case PHP_URL_HOST: + return $components['host']; + case PHP_URL_PORT: + return $components['port']; + } + throw new \InvalidArgumentException("Invalid component: ${component}"); + } +} \ No newline at end of file diff --git a/plugins/offline/cors/classes/ServiceProvider.php b/plugins/offline/cors/classes/ServiceProvider.php new file mode 100644 index 0000000..7138f9c --- /dev/null +++ b/plugins/offline/cors/classes/ServiceProvider.php @@ -0,0 +1,90 @@ +app->singleton(CorsService::class, function ($app) { + return new CorsService($this->getSettings()); + }); + } + + /** + * Return default Settings + */ + protected function getSettings() + { + $supportsCredentials = (bool)$this->getConfigValue('supportsCredentials', false); + $maxAge = (int)$this->getConfigValue('maxAge', 0); + $allowedOrigins = $this->getConfigValue('allowedOrigins', []); + $allowedHeaders = $this->getConfigValue('allowedHeaders', []); + $allowedMethods = $this->getConfigValue('allowedMethods', []); + $exposedHeaders = $this->getConfigValue('exposedHeaders', []); + + return compact( + 'supportsCredentials', + 'allowedOrigins', + 'allowedHeaders', + 'allowedMethods', + 'exposedHeaders', + 'maxAge' + ); + } + + /** + * Returns an effective config value. + * + * If a filesystem config is available it takes precedence + * over the backend settings values. + * + * @param $key + * @param null $default + * + * @return mixed + */ + public function getConfigValue($key, $default = null) + { + return $this->filesystemConfig($key) ?: $this->getValues(Settings::get($key, $default)); + } + + /** + * Return the filesystem config value if available. + * + * @param string $key + * + * @return mixed + */ + public function filesystemConfig($key) + { + return config('offline.cors::' . $key); + } + + /** + * Extract the repeater field values. + * + * @param mixed $values + * + * @return array + */ + protected function getValues($values) + { + return \is_array($values) ? collect($values)->pluck('value')->toArray() : $values; + } +} \ No newline at end of file diff --git a/plugins/offline/cors/composer.json b/plugins/offline/cors/composer.json new file mode 100644 index 0000000..4ce370d --- /dev/null +++ b/plugins/offline/cors/composer.json @@ -0,0 +1,13 @@ +{ + "name": "offline/oc-cors-plugin", + "description": "Setup and manage Cross-Origin Resource Sharing headers in October CMS", + "type": "october-plugin", + "license": "MIT", + "authors": [ + { + "name": "Tobias Kündig", + "email": "tobias@offline.swiss" + } + ], + "require": {} +} diff --git a/plugins/offline/cors/lang/de/lang.php b/plugins/offline/cors/lang/de/lang.php new file mode 100644 index 0000000..0df3bb1 --- /dev/null +++ b/plugins/offline/cors/lang/de/lang.php @@ -0,0 +1,6 @@ + [ + 'name' => 'CORS', + 'description' => 'Verwalte Cross-Origin Resource Sharing Header in October CMS', + ], +]; \ No newline at end of file diff --git a/plugins/offline/cors/lang/en/lang.php b/plugins/offline/cors/lang/en/lang.php new file mode 100644 index 0000000..beaa2e5 --- /dev/null +++ b/plugins/offline/cors/lang/en/lang.php @@ -0,0 +1,6 @@ + [ + 'name' => 'CORS', + 'description' => 'Setup and manage Cross-Origin Resource Sharing headers', + ], +]; \ No newline at end of file diff --git a/plugins/offline/cors/models/Settings.php b/plugins/offline/cors/models/Settings.php new file mode 100644 index 0000000..381d13f --- /dev/null +++ b/plugins/offline/cors/models/Settings.php @@ -0,0 +1,12 @@ +