openstackid/app/Http/Middleware/CORSMiddleware.php

455 lines
20 KiB
PHP

<?php namespace App\Http\Middleware;
/**
* Copyright 2015 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use Closure;
use Utils\Services\ICacheService;
use OAuth2\Models\IApiEndpoint;
use OAuth2\Repositories\IApiEndpointRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Cache;
use Carbon\Carbon;
use Illuminate\Support\Facades\Config;
use libs\utils\RequestUtils;
/**
*
* @package App\Http\Middleware\
* Implementation of http://www.w3.org/TR/cors/
*/
final class CORSMiddleware
{
const CORS_IP_BLACKLIST_PREFIX = 'CORS_IP_BLACKLIST_PREFIX:';
private $headers = array();
/**
* A header is said to be a simple header if the header field name is an ASCII case-insensitive match for Accept,
* Accept-Language, or Content-Language or if it is an ASCII case-insensitive match for Content-Type and the header
* field value media type (excluding parameters) is an ASCII case-insensitive match for
* application/x-www-form-urlencoded, multipart/form-data, or text/plain.
*/
protected static $simple_headers = array
(
'accept',
'accept-language',
'content-language',
'origin',
);
protected static $simple_content_header_values = array(
'application/x-www-form-urlencode',
'multipart/form-data',
'text/plain');
/**
* A method is said to be a simple method if it is a case-sensitive match for one of the following:
* - GET
* - HEAD
* - POST
*/
protected static $simple_http_methods = array('GET', 'HEAD', 'POST');
const DefaultAllowedHeaders = 'origin, content-type, accept, authorization, x-requested-with';
const DefaultAllowedMethods = 'GET, POST, OPTIONS, PUT, DELETE';
/**
* @var IApiEndpointRepository
*/
private $endpoint_repository;
/**
* @var IApiEndpoint;
*/
private $current_endpoint = null;
private $allowed_headers;
private $allowed_methods;
/**
* @var ICacheService
*/
private $cache_service;
public function __construct(IApiEndpointRepository $endpoint_repository, ICacheService $cache_service)
{
$this->endpoint_repository = $endpoint_repository;
$this->cache_service = $cache_service;
$this->allowed_headers = Config::get('cors.allowed_headers', self::DefaultAllowedHeaders);
$this->allowed_methods = Config::get('cors.allowed_methods', self::DefaultAllowedMethods);
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($response = $this->preProcess($request)) {
return $response;
}
//normal processing
$response = $next($request);
$this->postProcess($request, $response);
return $response;
}
private function generatePreflightCacheKey($request)
{
$cache_id = 'pre-flight-' . $request->getClientIp() . '-' . $request->getRequestUri() . '-' . $request->getMethod();
return $cache_id;
}
/**
* @param Request $request
* @return Response
*/
public function preProcess(Request $request)
{
$actual_request = false;
if ($this->isValidCORSRequest($request)) {
if (!$this->testOriginHeaderScrutiny($request)) {
$response = new Response();
$response->setStatusCode(403);
return $response;
}
/* Step 01 : Determine the type of the incoming request */
$type = $this->getRequestType($request);
/* Step 02 : Process request according to is type */
switch ($type) {
case CORSRequestPreflightType::REQUEST_FOR_PREFLIGHT: {
// HTTP request send by client to preflight a further 'Complex' request
// sets the original method on request in order to be able to find the
// correct route
$real_method = $request->headers->get('Access-Control-Request-Method');
$route_path = RequestUtils::getCurrentRoutePath($request);
if (strpos($route_path, '/') != 0)
$route_path = '/' . $route_path;
$request->setMethod($real_method);
if (!$route_path || !$this->checkEndPoint($route_path, $real_method)) {
$response = new Response();
$response->setStatusCode(403);
return $response;
}
// ----Step 2b: Store pre-flight request data in the Cache to keep (mark) the request as correctly followed the request pre-flight process
$data = new CORSRequestPreflightData($request, $this->current_endpoint->supportCredentials());
$cache_id = $this->generatePreflightCacheKey($request);
$this->cache_service->storeHash($cache_id, $data->toArray(), CORSRequestPreflightData::$cache_lifetime);
// ----Step 2c: Return corresponding response - This part should be customized with application specific constraints.....
return $this->makePreflightResponse($request);
}
break;
case CORSRequestPreflightType::COMPLEX_REQUEST: {
$cache_id = $this->generatePreflightCacheKey($request);; // ----Step 2a: Check if the current request has an entry into the preflighted requests Cache
$data = $this->cache_service->getHash($cache_id, CORSRequestPreflightData::$cache_attributes);
if (!count($data)) {
$response = new Response();
$response->setStatusCode(403);
return $response;
}
// ----Step 2b: Check that pre-flight information declared during the pre-flight request match the current request on key information
$match = false;
// ------Start with comparison of "Origin" HTTP header (according to utility method impl. used to retrieve header reference cannot be null)...
if ($request->headers->get('Origin') === $data['origin']) {
// ------Continue with HTTP method...
if ($request->getMethod() === $data['expected_method']) {
// ------Finish with custom HTTP headers (use an method to avoid manual iteration on collection to increase the speed)...
$x_headers = self::getCustomHeaders($request);
$x_headers_pre = explode(',', $data['expected_custom_headers']);
sort($x_headers);
sort($x_headers_pre);
if (count(array_diff($x_headers, $x_headers_pre)) === 0) {
$match = true;
}
}
}
if (!$match) {
$response = new Response();
$response->setStatusCode(403);
return $response;
}
$actual_request = true;
}
break;
case CORSRequestPreflightType::SIMPLE_REQUEST: {
// origins, do not set any additional headers and terminate this set of steps.
if (!$this->isAllowedOrigin($request)) {
$response = new Response();
$response->setStatusCode(403);
return $response;
}
$actual_request = true;
// If the resource supports credentials add a single Access-Control-Allow-Origin header, with the value
// of the Origin header as value, and add a single Access-Control-Allow-Credentials header with the
// case-sensitive string "true" as value.
// Otherwise, add a single Access-Control-Allow-Origin header, with either the value of the Origin header
// or the string "*" as value.
}
break;
}
}
if ($actual_request) {
// Save response headers
$cache_id = $this->generatePreflightCacheKey($request);
// ----Step 2a: Check if the current request has an entry into the preflighted requests Cache
$data = $this->cache_service->getHash($cache_id, CORSRequestPreflightData::$cache_attributes);
$this->headers['Access-Control-Allow-Origin'] = $request->headers->get('Origin');
if ((isset($data['allows_credentials']) && (bool)$data['allows_credentials'])) {
$this->headers['Access-Control-Allow-Credentials'] = 'true';
}
/**
* During a CORS request, the getResponseHeader() method can only access simple response headers.
* Simple response headers are defined as follows:
** Cache-Control
** Content-Language
** Content-Type
** Expires
** Last-Modified
** Pragma
* If you want clients to be able to access other headers,
* you have to use the Access-Control-Expose-Headers header.
* The value of this header is a comma-delimited list of response headers you want to expose
* to the client.
*/
$exposed_headers = Config::get('cors.exposed_headers', 'Content-Type, Expires');
if (!empty($exposed_headers)) {
$this->headers['Access-Control-Expose-Headers'] = $exposed_headers;
}
}
}
public function postProcess(Request $request, Response $response)
{
// add CORS response headers
if (count($this->headers) > 0) {
$response->headers->add($this->headers);
}
return $response;
}
/**
* @param Request $request
* @return Response
*/
private function makePreflightResponse(Request $request)
{
$response = new Response();
if (!$this->isAllowedOrigin($request)) {
$response->headers->set('Access-Control-Allow-Origin', 'null');
$response->setStatusCode(403);
return $response;
}
$response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
// The Access-Control-Request-Method header indicates which method will be used in the actual
// request as part of the preflight request
// check request method
if ($request->headers->get('Access-Control-Request-Method') != $this->current_endpoint->getHttpMethod()) {
$response->setStatusCode(405);
return $response;
}
// The Access-Control-Allow-Credentials header indicates whether the response to request
// can be exposed when the omit credentials flag is unset. When part of the response to a preflight request
// it indicates that the actual request can include user credentials.
if ($this->current_endpoint->supportCredentials()) {
$response->headers->set('Access-Control-Allow-Credentials', 'true');
}
if (Config::get('cors.use_pre_flight_caching', false)) {
// The Access-Control-Max-Age header indicates how long the response can be cached, so that for
// subsequent requests, within the specified time, no preflight request has to be made.
$response->headers->set('Access-Control-Max-Age', Config::get('cors.max_age', 32000));
}
// The Access-Control-Allow-Headers header indicates, as part of the response to a preflight request,
// which header field names can be used during the actual request
$response->headers->set('Access-Control-Allow-Headers', $this->allowed_headers);
//The Access-Control-Allow-Methods header indicates, as part of the response to a preflight request,
// which methods can be used during the actual request.
$response->headers->set('Access-Control-Allow-Methods', $this->allowed_methods);
// The Access-Control-Request-Headers header indicates which headers will be used in the actual request
// as part of the preflight request.
$headers = $request->headers->get('Access-Control-Request-Headers');
if ($headers) {
$headers = trim(strtolower($headers));
$allow_headers = explode(', ', $this->allowed_headers);
foreach (preg_split('{, *}', $headers) as $header) {
//if they are simple headers then skip them
if (in_array($header, self::$simple_headers, true)) {
continue;
}
//check is the requested header is on the list of allowed headers
if (!in_array($header, $allow_headers, true)) {
$response->setStatusCode(400);
$response->setContent('Unauthorized header ' . $header);
break;
}
}
}
//OK - No Content
$response->setStatusCode(204);
return $response;
}
/**
* @param Request $request
* @returns bool
*/
private function isValidCORSRequest(Request $request)
{
/**
* The presence of the Origin header does not necessarily mean that the request is a cross-origin request.
* While all cross-origin requests will contain an Origin header,
* Origin header on same-origin requests. But Chrome and Safari include an Origin header on
* same-origin POST/PUT/DELETE requests (same-origin GET requests will not have an Origin header).
*/
return $request->headers->has('Origin');
}
/**
* https://www.owasp.org/index.php/CORS_OriginHeaderScrutiny
* Filter that will ensure the following points for each incoming HTTP CORS requests:
* - Have only one and non empty instance of the origin header,
* - Have only one and non empty instance of the host header,
* - The value of the origin header is present in a internal allowed domains list (white list). As we act before the
* step 2 of the CORS HTTP requests/responses exchange process, allowed domains list is yet provided to client,
* - Cache IP of the sender for 1 hour. If the sender send one time a origin domain that is not in the white list
* then all is requests will return an HTTP 403 response (protract allowed domain guessing).
* We use the method above because it's not possible to identify up to 100% that the request come from one expected
* client application, since:
* - All information of a HTTP request can be faked,
* - It's the browser (or others tools) that send the HTTP request then the IP address that we have access to is the
* client IP address.
* @param Request $request
* @return bool
*/
private function testOriginHeaderScrutiny(Request $request)
{
/* Step 0 : Check presence of client IP in black list */
$client_ip = $request->getClientIp();
if (Cache::has(self::CORS_IP_BLACKLIST_PREFIX . $client_ip)) {
return false;
}
/* Step 1 : Check that we have only one and non empty instance of the "Origin" header */
$origin = $request->headers->get('Origin', null, false);
if (is_array($origin) && count($origin) > 1) {
// If we reach this point it means that we have multiple instance of the "Origin" header
// Add client IP address to black listed client
$expiresAt = Carbon::now()->addMinutes(60);
Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt);
return false;
}
/* Step 2 : Check that we have only one and non empty instance of the "Host" header */
$host = $request->headers->get('Host', null, false);
//Have only one and non empty instance of the host header,
if (is_array($host) && count($host) > 1) {
// If we reach this point it means that we have multiple instance of the "Host" header
$expiresAt = Carbon::now()->addMinutes(60);
Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt);
return false;
}
/* Step 3 : Perform analysis - Origin header is required */
$origin = $request->headers->get('Origin');
$host = $request->headers->get('Host');
$server_name = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : null;
// check origin not empty and allowed
if (!$this->isAllowedOrigin($origin)) {
$expiresAt = Carbon::now()->addMinutes(60);
Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt);
return false;
}
if (is_null($host) || $server_name != $host) {
$expiresAt = Carbon::now()->addMinutes(60);
Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt);
return false;
}
/* Step 4 : Finalize request next step */
return true;
}
private function checkEndPoint($endpoint_path, $http_method)
{
$this->current_endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($endpoint_path, $http_method);
if (is_null($this->current_endpoint)) {
return false;
}
if (!$this->current_endpoint->supportCORS() || !$this->current_endpoint->isActive()) {
return false;
}
return true;
}
/**
* @param string $origin
* @return bool
*/
private function isAllowedOrigin($origin)
{
return true;
}
private static function getRequestType(Request $request)
{
$type = CORSRequestPreflightType::UNKNOWN;
$http_method = $request->getMethod();
$content_type = strtolower($request->getContentType());
$http_method = strtoupper($http_method);
if ($http_method === 'OPTIONS' && $request->headers->has('Access-Control-Request-Method')) {
$type = CORSRequestPreflightType::REQUEST_FOR_PREFLIGHT;
} else {
if (self::hasCustomHeaders($request)) {
$type = CORSRequestPreflightType::COMPLEX_REQUEST;
} elseif ($http_method === 'POST' && !in_array($content_type, self::$simple_content_header_values, true)) {
$type = CORSRequestPreflightType::COMPLEX_REQUEST;
} elseif (!in_array($http_method, self::$simple_http_methods, true)) {
$type = CORSRequestPreflightType::COMPLEX_REQUEST;
} else {
$type = CORSRequestPreflightType::SIMPLE_REQUEST;
}
}
return $type;
}
private static function getCustomHeaders(Request $request)
{
$custom_headers = array();
foreach ($request->headers->all() as $k => $h) {
if (starts_with('X-', strtoupper(trim($k)))) {
array_push($custom_headers, strtoupper(trim($k)));
}
}
return $custom_headers;
}
private static function hasCustomHeaders(Request $request)
{
return count(self::getCustomHeaders($request)) > 0;
}
}