Skip to content
Snippets Groups Projects
Verified Commit 0c59e6f1 authored by Kirill Roskolii's avatar Kirill Roskolii
Browse files

Issue #3280262 by RoSk0: Support more use cases

parent 9679b680
Branches 8.x-1.x
Tags 2.0.x-dev 8.x-1.0-beta3
No related merge requests found
......@@ -92,3 +92,12 @@ function cloudflare_update_8004(&$sandbox) {
$config->set('auth_using', 'key')->save();
}
}
/**
* Set `remote_addr_validate` on existing installations.
*/
function cloudflare_update_8005() {
\Drupal::configFactory()->getEditable('cloudflare.settings')->set('remote_addr_validate', TRUE)->save();
return 'Cloudflare configuration ( cloudflare.settings ) was successfully updated.';
}
......@@ -21,8 +21,10 @@ services:
logger.channel.cloudflare:
parent: logger.channel_base
arguments: ['cloudflare']
cloudflare.clientiprestore:
class: Drupal\cloudflare\EventSubscriber\ClientIpRestore
http_middleware.cloudflare:
class: Drupal\cloudflare\CloudFlareMiddleware
arguments: ['@config.factory', '@cache.data', '@http_client', '@logger.channel.cloudflare']
tags:
- { name: event_subscriber }
# Same priority as reverse proxy middleware (http_middleware.reverse_proxy).
- { name: http_middleware, priority: 300 }
{
"name": "drupal/cloudflare",
"type": "drupal-module",
"description": "Drupal module for interacting with CloudFlare's SDK. CloudFlare is a copyright of CloudFlare, Inc. The authors of this tool has no association with CloudFlare, Inc.",
"keywords": ["Drupal", "CloudFlare", "Cloud Flare", "CDN"],
"homepage": "https://www.drupal.org/project/cloudflare",
"license": "GPL-2.0+",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": [
{
"type": "composer",
"url": "https://packages.drupal.org/8"
"name": "drupal/cloudflare",
"type": "drupal-module",
"description": "Drupal module for interacting with CloudFlare's SDK. CloudFlare is a copyright of CloudFlare, Inc. The authors of this tool has no association with CloudFlare, Inc.",
"keywords": ["Drupal", "CloudFlare", "Cloud Flare", "CDN"],
"homepage": "https://www.drupal.org/project/cloudflare",
"license": "GPL-2.0+",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"drupalorg": {
"type": "composer",
"url": "https://packages.drupal.org/8"
}
},
"require": {
"ext-json": "*",
"cloudflare/sdk": "^1",
"drupal/ctools": "^3.0"
},
"require-dev": {
"drupal/purge": "^3.0"
}
],
"require": {
"cloudflare/sdk": "^1",
"drupal/ctools": "^3.0"
},
"require-dev": {
"drupal/purge": "^3.0"
}
}
client_ip_restore_enabled: false
remote_addr_validate: true
bypass_host: ''
valid_credentials: false
zone_id: { }
......
......@@ -7,6 +7,10 @@ cloudflare.settings:
label: 'Restore Client Ip Address.'
type: boolean
translatable: false
remote_addr_validate:
label: 'Validate remote IP address'
type: boolean
translatable: false
bypass_host:
label: 'Host to Bypass CloudFlare. Helps suppress log warnings regarding requests bypassing CloudFlare.'
type: string
......
<?php
namespace Drupal\cloudflare\EventSubscriber;
namespace Drupal\cloudflare;
use Drupal\Core\Url;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\IpUtils;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Restores the true client Ip address.
*
* @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-CloudFlare-handle-HTTP-Request-headers-
* @see https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/
*/
class ClientIpRestore implements EventSubscriberInterface {
class CloudFlareMiddleware implements HttpKernelInterface {
use StringTranslationTrait;
const CLOUDFLARE_RANGE_KEY = 'cloudflare_range_key';
const CLOUDFLARE_CLIENT_IP_RESTORE_ENABLED = 'client_ip_restore_enabled';
const CLOUDFLARE_REMOTE_ADDR_VALIDATE = 'remote_addr_validate';
const CLOUDFLARE_BYPASS_HOST = 'bypass_host';
const IPV4_ENDPOINTS_URL = 'https://www.cloudflare.com/ips-v4';
const IPV6_ENDPOINTS_URL = 'https://www.cloudflare.com/ips-v6';
/**
* The kernel.
*
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
*/
protected $httpKernel;
/**
* Cache backend service.
*
......@@ -65,57 +73,65 @@ class ClientIpRestore implements EventSubscriberInterface {
protected $isClientIpRestoreEnabled;
/**
* Constructs a ClientIpRestore.
* Validate remote IP address.
*
* @var bool
*/
protected $remoteAddrValidate;
/**
* Constructs the CloudflareMiddleware object.
*
* @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
* The decorated kernel.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* Configuration factory.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* Cache backend.
* Cache.
* @param \GuzzleHttp\ClientInterface $http_client
* A Guzzle client object.
* HTTP client.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* Logger.
*/
public function __construct(ConfigFactoryInterface $config_factory, CacheBackendInterface $cache, ClientInterface $http_client, LoggerInterface $logger) {
public function __construct(HttpKernelInterface $http_kernel, ConfigFactoryInterface $config_factory, CacheBackendInterface $cache, ClientInterface $http_client, LoggerInterface $logger) {
$this->httpKernel = $http_kernel;
$this->httpClient = $http_client;
$this->cache = $cache;
$this->config = $config_factory->get('cloudflare.settings');
$this->logger = $logger;
$this->isClientIpRestoreEnabled = $this->config->get(self::CLOUDFLARE_CLIENT_IP_RESTORE_ENABLED);
$this->remoteAddrValidate = $this->config->get(self::CLOUDFLARE_REMOTE_ADDR_VALIDATE);
$this->bypassHost = $this->config->get(self::CLOUDFLARE_BYPASS_HOST);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = ['onRequest', 20];
return $events;
}
public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
if ($type !== self::MASTER_REQUEST) {
return $this->httpKernel->handle($request, $type, $catch);
}
/**
* Restores the origination client IP delivered to Drupal from CloudFlare.
*/
public function onRequest(GetResponseEvent $event) {
if (!$this->isClientIpRestoreEnabled) {
return;
return $this->httpKernel->handle($request, $type, $catch);
}
$current_request = $event->getRequest();
$cf_connecting_ip = $current_request->server->get('HTTP_CF_CONNECTING_IP');
$cf_connecting_ip = $request->server->get('HTTP_CF_CONNECTING_IP', '');
$has_http_cf_connecting_ip = !empty($cf_connecting_ip);
$remoteAddrValidate = $this->remoteAddrValidate;
$has_bypass_host = !empty($this->bypassHost);
$client_ip = $current_request->getClientIp();
$incoming_uri = $current_request->getHost();
$client_ip = $request->getClientIp();
$incoming_uri = $request->getHost();
$request_expected_to_bypass_cloudflare = $has_bypass_host && $this->bypassHost == $incoming_uri;
if ($request_expected_to_bypass_cloudflare) {
return;
return $this->httpKernel->handle($request, $type, $catch);
}
if (!$has_http_cf_connecting_ip) {
$message = $this->t("Request came through without being routed through CloudFlare.");
$this->logger->warning($message);
return;
return $this->httpKernel->handle($request, $type, $catch);
}
$has_ip_already_changed = $client_ip == $cf_connecting_ip;
......@@ -127,27 +143,38 @@ class ClientIpRestore implements EventSubscriberInterface {
$link_to_settings = $url_to_settings->getInternalPath();
$message = $this->t('Request has already been updated. This functionality should be deactivated. Please go <a href="@link_to_settings">here</a> to disable "Restore Client Ip Address".', ['@link_to_settings' => $link_to_settings]);
$this->logger->warning($message);
return;
return $this->httpKernel->handle($request, $type, $catch);
}
$cloudflare_ipranges = $this->getCloudFlareIpRanges();
$request_originating_from_cloudflare = IpUtils::checkIp($client_ip, $cloudflare_ipranges);
if ($has_http_cf_connecting_ip && !$request_originating_from_cloudflare) {
if ($remoteAddrValidate && $has_http_cf_connecting_ip && !$request_originating_from_cloudflare) {
$message = $this->t("Client IP of @client_ip does not match a known CloudFlare IP but there is HTTP_CF_CONNECTING_IP of @cf_connecting_ip.", [
'@cf_connecting_ip' => $cf_connecting_ip,
'@client_ip' => $client_ip,
]);
$this->logger->warning($message);
return;
return $this->httpKernel->handle($request, $type, $catch);
}
// As the changed remote address will make it impossible to determine
// a trusted proxy, we need to make sure we set the right protocal as well.
// @see \Symfony\Component\HttpFoundation\Request::isSecure()
$event->getRequest()->server->set('HTTPS', $event->getRequest()->isSecure() ? 'on' : 'off');
$event->getRequest()->server->set('REMOTE_ADDR', $cf_connecting_ip);
$event->getRequest()->overrideGlobals();
// a trusted proxy, we need to make sure we set the right protocol as well.
// Using incoming request to determine scheme that should be used will not
// work in configurations where TLS is off-loaded before the server that
// hosts Drupal, but Cloudflare tells us if original request was secure.
// @see https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/#cf-visitor
$cf_visitor = json_decode($request->server->get('HTTP_CF_VISITOR', '{}'), TRUE);
// Use current request as a fall back.
$is_secure = $request->isSecure();
if (!empty($cf_visitor['scheme'])) {
$is_secure = strtolower($cf_visitor['scheme']) === 'https';
}
$request->server->set('HTTPS', $is_secure ? 'on' : 'off');
$request->server->set('REMOTE_ADDR', $cf_connecting_ip);
$request->overrideGlobals();
return $this->httpKernel->handle($request, $type, $catch);
}
/**
......
......@@ -304,6 +304,13 @@ class SettingsForm extends FormBase implements ContainerInjectionInterface {
'#default_value' => $config->get('client_ip_restore_enabled'),
];
$section['cloudflare_config']['remote_addr_validate'] = [
'#type' => 'checkbox',
'#title' => $this->t('Validate remote IP address'),
'#description' => $this->t('<strong>WARNING: disabling this can have security related consequences. Leave enabled if unsure.</strong> <br /> When "Restore Client Ip Address" above is enabled, this module will validate that the request is originating from <a href="https://www.cloudflare.com/ips/">Cloudflare IPs</a> before replacing it with the IP address provided in <a href="https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/#cf-connecting-ip">CF-Connecting-IP</a> header. For example, when your Drupal is running in Kubernetes, this remote IP might be of your ingress controller and not originating from Cloudflare, so you want to disable this validation.'),
'#default_value' => $config->get('remote_addr_validate'),
];
$section['cloudflare_config']['bypass_host'] = [
'#type' => 'textfield',
'#title' => $this->t('Host to Bypass CloudFlare'),
......@@ -430,6 +437,7 @@ class SettingsForm extends FormBase implements ContainerInjectionInterface {
// Deslash the host URL.
$bypass_host = trim(rtrim($form_state->getValue('bypass_host'), "/"));
$client_ip_restore_enabled = $form_state->getValue('client_ip_restore_enabled');
$remote_addr_validate = $form_state->getValue('remote_addr_validate');
$config = $this->configFactory->getEditable('cloudflare.settings');
$config
......@@ -440,6 +448,7 @@ class SettingsForm extends FormBase implements ContainerInjectionInterface {
->set('email', $email)
->set('valid_credentials', TRUE)
->set('bypass_host', $bypass_host)
->set('remote_addr_validate', $remote_addr_validate)
->set('client_ip_restore_enabled', $client_ip_restore_enabled);
$config->save();
}
......
<?php
namespace Drupal\Tests\cloudflare\Functional;
use Drupal\Tests\BrowserTestBase;
use GuzzleHttp\Cookie\CookieJar;
use Symfony\Component\HttpFoundation\Request;
/**
* Test authentication support and intermediaries.
*
* Based on Drupal\Tests\system\Functional\Session\SessionHttpsTest::testHttpsSession().
*
* @group cloudflare
*/
class VariousUseCasesTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['cloudflare'];
/**
* The name of the session cookie when using HTTPS.
*
* @var string
*/
protected $secureSessionName;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$request = Request::createFromGlobals();
if ($request->isSecure()) {
$this->secureSessionName = $this->getSessionName();
}
else {
$this->secureSessionName = 'S' . $this->getSessionName();
}
$this->container->get('config.factory')->getEditable('cloudflare.settings')
->set('client_ip_restore_enabled', TRUE)
->set('remote_addr_validate', FALSE)
->save();
}
/**
* Test that authenticated user will receive session cookie with secure flag
* set and will be redirected to the HTTPS website version when connected over
* CloudFlare in flexible encryption mode or when there are intermediaries
* between CloudFlare and the origin server.
*/
public function testAutheticationSupport() {
$this->assertSame(TRUE, $this->config('cloudflare.settings')->get('client_ip_restore_enabled'), 'Restore client IP address function is enabled');
$this->assertSame(FALSE, $this->config('cloudflare.settings')->get('remote_addr_validate'), 'Validation of remote IP address is disabled');
$account = $this->drupalCreateUser(['access administration pages']);
$guzzle_cookie_jar = $this->getGuzzleCookieJar();
$post = [
'form_id' => 'user_login_form',
'form_build_id' => $this->getUserLoginFormBuildId(),
'name' => $account->getAccountName(),
'pass' => $account->passRaw,
'op' => 'Log in',
];
$url = $this->buildUrl($this->httpUrl('user/login'));
// When posting directly to the HTTP or http mock front controller, the
// location header on the returned response is an absolute URL. That URL
// needs to be converted into a request to the respective mock front
// controller in order to retrieve the target page. Because the URL in the
// location header needs to be modified, it is necessary to disable the
// automatic redirects normally performed by the Guzzle CurlHandler.
/** @var \Psr\Http\Message\ResponseInterface $response */
$response = $this->getHttpClient()->post($url, [
'form_params' => $post,
'http_errors' => FALSE,
'cookies' => $guzzle_cookie_jar,
'allow_redirects' => FALSE,
// Mock CloudFlare headers.
'headers' => [
'CF-Connecting-IP' => '127.0.0.11',
'CF-Visitor' => '{"scheme":"https"}',
]
]);
$this->assertEquals(303, $response->getStatusCode(), 'User is redirected to the profile page');
$this->assertStringStartsWith('https', $response->getHeader('location')[0], 'Location header contains expected HTTPS scheme');
$cookie = $guzzle_cookie_jar->getCookieByName($this->secureSessionName);
$this->assertTrue(is_a($cookie, 'GuzzleHttp\Cookie\SetCookie'), 'The secure cookie exists');
$this->assertTrue($cookie->getSecure(), 'The secure cookie has the secure attribute');
}
/**
* Creates a new Guzzle CookieJar with a Xdebug cookie if necessary.
*
* @return \GuzzleHttp\Cookie\CookieJar
* The Guzzle CookieJar.
*/
protected function getGuzzleCookieJar() {
$cookies = $this->extractCookiesFromRequest(\Drupal::request());
foreach ($cookies as $cookie_name => $values) {
$cookies[$cookie_name] = $values[0];
}
return CookieJar::fromArray($cookies, $this->baseUrl);
}
/**
* Gets the form build ID for the user login form.
*
* @return string
* The form build ID for the user login form.
*/
protected function getUserLoginFormBuildId() {
$this->drupalGet('user/login');
return (string) $this->getSession()->getPage()->findField('form_build_id');
}
/**
* Builds a URL for submitting a mock HTTPS request to HTTP test environments.
*
* @param $url
* A Drupal path such as 'user/login'.
*
* @return string
* URL prepared for the https.php mock front controller.
*/
protected function httpsUrl($url) {
return 'core/modules/system/tests/https.php/' . $url;
}
/**
* Builds a URL for submitting a mock HTTP request to HTTPS test environments.
*
* @param $url
* A Drupal path such as 'user/login'.
*
* @return string
* URL prepared for the http.php mock front controller.
*/
protected function httpUrl($url) {
return 'core/modules/system/tests/http.php/' . $url;
}
}
......@@ -2,10 +2,10 @@
namespace Drupal\Tests\cloudflare\Unit;
use Drupal\cloudflare\CloudFlareMiddleware;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\cloudflare\EventSubscriber\ClientIpRestore;
use Drupal\Tests\UnitTestCase;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerInterface;
......@@ -18,7 +18,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
*
* @group cloudflare
*
* @covers \Drupal\cloudflare\EventSubscriber\ClientIpRestffore
* @covers \Drupal\cloudflare\CloudFlareMiddleware
*/
class ClientIpRestoreTest extends UnitTestCase {
use StringTranslationTrait;
......@@ -90,13 +90,17 @@ class ClientIpRestoreTest extends UnitTestCase {
// Create a map of arguments to return values.
$map = [
[
ClientIpRestore::CLOUDFLARE_BYPASS_HOST,
CloudFlareMiddleware::CLOUDFLARE_BYPASS_HOST,
$bypass_host
],
[
ClientIpRestore::CLOUDFLARE_CLIENT_IP_RESTORE_ENABLED,
CloudFlareMiddleware::CLOUDFLARE_CLIENT_IP_RESTORE_ENABLED,
$client_ip_restore_enabled,
],
[
CloudFlareMiddleware::CLOUDFLARE_REMOTE_ADDR_VALIDATE,
TRUE,
]
];
$config->expects($this->atLeastOnce())
->method('get')
......@@ -124,10 +128,12 @@ class ClientIpRestoreTest extends UnitTestCase {
);
$cf_ips = array_map('trim', $cf_ips);
$cache_backend = new MemoryBackend('foo');
$cache_backend->set(ClientIpRestore::CLOUDFLARE_RANGE_KEY, $cf_ips);
$cache_backend = new MemoryBackend();
$cache_backend->set(CloudFlareMiddleware::CLOUDFLARE_RANGE_KEY, $cf_ips);
$kernel = $this->createMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$client_ip_restore = new ClientIpRestore(
$cf_middleware = new CloudFlareMiddleware(
$kernel,
$config_factory,
$cache_backend,
$this->createMock(ClientInterface::class),
......@@ -135,7 +141,6 @@ class ClientIpRestoreTest extends UnitTestCase {
);
$request = Request::create('/test', 'get');
$kernel = $this->createMock('Symfony\Component\HttpKernel\HttpKernelInterface');
if (!empty($cf_header)) {
$request->server->set('HTTP_CF_CONNECTING_IP', $cf_header);
......@@ -150,14 +155,12 @@ class ClientIpRestoreTest extends UnitTestCase {
}
$request->overrideGlobals();
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST);
$client_ip_restore->onRequest($event);
$cf_middleware->handle($request, HttpKernelInterface::MASTER_REQUEST);
$this->assertEquals($expected_client_ip, $request->getClientIp());
}
/**
* Provider for testing ClientIpRestoreProvider.
* Provider for testing testEnabledClientIpRestoreProvider.
*
* @return array
* Test Data to simulate incoming request and the expected results..
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment