From 2c907640067d026ba5e702d2b50e2d99eca851e7 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Thu, 5 Jan 2023 19:42:04 +0900 Subject: [PATCH 01/10] feat(*): Use remediation engine --- .github/workflows/test-suite.yml | 13 +- .gitignore | 5 +- composer.json | 10 +- docs/DEVELOPER.md | 8 - scripts/auto-prepend/bounce.php | 6 +- scripts/auto-prepend/refresh-cache.php | 8 +- scripts/auto-prepend/settings.example.php | 80 +- scripts/clear-cache.php | 19 +- scripts/full-example-live-mode.php | 56 -- .../public/cache-actions.php | 26 +- .../public/geolocation-test.php | 43 +- scripts/refresh-cache-stream.php | 35 + scripts/refresh-cache.php | 32 - ...ck-ip.php => standalone-check-ip-live.php} | 16 +- src/AbstractBounce.php | 494 ------------- src/AbstractBouncer.php | 687 ++++++++++++++++++ src/AbstractCache.php | 684 ----------------- src/ApiCache.php | 636 ---------------- src/ApiClient.php | 97 --- src/Bouncer.php | 332 --------- src/{IBounce.php => BouncerInterface.php} | 34 +- src/Configuration.php | 245 +++---- src/Geolocation.php | 179 ----- src/Remediation.php | 66 -- src/RestClient/AbstractClient.php | 73 -- src/RestClient/Curl.php | 142 ---- src/RestClient/FileGetContents.php | 113 --- src/StandaloneBounce.php | 360 --------- src/StandaloneBouncer.php | 198 +++++ src/Template.php | 3 - src/TemplateConfiguration.php | 90 --- src/templates/ban.html.twig | 2 +- src/templates/captcha.html.twig | 2 +- tests/Integration/GeolocationTest.php | 51 +- tests/Integration/IpVerificationTest.php | 88 ++- tests/Integration/TestHelpers.php | 2 +- tests/Integration/WatcherClient.php | 129 ++-- tests/end-to-end/__tests__/3-stream-mode.js | 2 +- tests/end-to-end/settings/base.php.dist | 75 +- tests/end-to-end/utils/helpers.js | 2 +- tests/end-to-end/utils/watcherClient.js | 1 - .../php-cs-fixer/.php-cs-fixer.dist.php | 2 +- 42 files changed, 1321 insertions(+), 3825 deletions(-) delete mode 100644 scripts/full-example-live-mode.php rename tests/end-to-end/php-scripts/cache-actions.php.dist => scripts/public/cache-actions.php (60%) rename tests/end-to-end/php-scripts/geolocation-test.php.dist => scripts/public/geolocation-test.php (60%) create mode 100644 scripts/refresh-cache-stream.php delete mode 100644 scripts/refresh-cache.php rename scripts/{check-ip.php => standalone-check-ip-live.php} (69%) delete mode 100644 src/AbstractBounce.php create mode 100644 src/AbstractBouncer.php delete mode 100644 src/AbstractCache.php delete mode 100644 src/ApiCache.php delete mode 100644 src/ApiClient.php delete mode 100644 src/Bouncer.php rename src/{IBounce.php => BouncerInterface.php} (57%) delete mode 100644 src/Geolocation.php delete mode 100644 src/Remediation.php delete mode 100644 src/RestClient/AbstractClient.php delete mode 100644 src/RestClient/Curl.php delete mode 100644 src/RestClient/FileGetContents.php delete mode 100644 src/StandaloneBounce.php create mode 100644 src/StandaloneBouncer.php delete mode 100644 src/TemplateConfiguration.php diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 5c609b8..d96ed0c 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -97,31 +97,29 @@ jobs: - name: Run "IP verification with file_get_contents" test run: | - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "IP verification with cURL" test run: | - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "IP verification with TLS" test run: | - ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + #ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "Geolocation with file_get_contents" test run: | - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php + #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php - name: Run "Geolocation with cURL" test run: | - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php + #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php - name: Prepare Standalone Bouncer end-to-end tests run: | cd ${{ github.workspace }}/.ddev ddev nginx-config custom_files/crowdsec-prepend-nginx-site.conf cd ${{ github.workspace }} - cp ${{env.EXTENSION_PATH}}/tests/end-to-end/php-scripts/cache-actions.php.dist ${{env.EXTENSION_PATH}}/scripts/public/cache-actions.php - cp ${{env.EXTENSION_PATH}}/tests/end-to-end/php-scripts/geolocation-test.php.dist ${{env.EXTENSION_PATH}}/scripts/public/geolocation-test.php cp ${{env.EXTENSION_PATH}}/tests/end-to-end/settings/base.php.dist crowdsec-lib-settings.php sed -i -e 's/REPLACE_API_KEY/${{ env.BOUNCER_KEY }}/g' crowdsec-lib-settings.php sed -i -e 's/REPLACE_PROXY_IP/${{ env.PROXY_IP }}/g' crowdsec-lib-settings.php @@ -201,7 +199,6 @@ jobs: run: | cd ${{ github.workspace }}/${{env.EXTENSION_PATH}} sed -i 's/\x27use_curl\x27 => false/\x27use_curl\x27 => true/g' scripts/auto-prepend/settings.php - sed -i 's/\x27use_curl\x27 => true/\x27use_curl\x27 => false/g' scripts/auto-prepend/settings.php sed -i 's/\x27enabled\x27 => false/\x27enabled\x27 => true/g' scripts/auto-prepend/settings.php sed -i 's/\x27forced_test_forwarded_ip\x27 => \x27\x27/\x27forced_test_forwarded_ip\x27 => \x27${{env.JP_TEST_IP}}\x27/g' scripts/auto-prepend/settings.php cat scripts/auto-prepend/settings.php diff --git a/.gitignore b/.gitignore index 73d9065..c55d0b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Composer vendor composer.lock +composer-dev.* # Systems .DS_Store @@ -22,9 +23,5 @@ scripts/auto-prepend/.logs scripts/auto-prepend/.cache scripts/**/*.log -# Public scripts -scripts/public/cache-actions.php -scripts/public/geolocation-test.php - # MaxMind databases *.mmdb \ No newline at end of file diff --git a/composer.json b/composer.json index c685fb7..a100132 100644 --- a/composer.json +++ b/composer.json @@ -40,20 +40,14 @@ ], "require": { "php": ">=7.2.5", + "crowdsec/remediation-engine": "0.6.0", "symfony/config": "^4.4.27 || ^5.2 || ^6.0", - "symfony/cache": "^5.4.11 || ^6.0.11", "twig/twig": "^3.4.2", - "monolog/monolog": "^1.17 || ^2.1", "gregwar/captcha": "^1.1", "mlocati/ip-lib": "^1.18", - "geoip2/geoip2": "^2.12.2", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^8.5.30 || ^9.3", - "ext-curl": "*" - }, - "suggest": { - "ext-curl": "*" + "phpunit/phpunit": "^8.5.30 || ^9.3" } } diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 099dbf8..5856258 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -265,14 +265,6 @@ https://phpXX.ddev.site/my-own-modules/crowdsec-php-lib/scripts/public/protected In auto-prepend mode, you can run some end-to-end tests. -Before running the tests, you have to copy some testing scripts: - -``` -cd php-project-sources -cp .ddev/custom_files/crowdsec/cache-actions.php my-own-modules/crowdsec-php-lib/scripts/public/cache-actions.php -cp .ddev/custom_files/crowdsec/geolocation-test.php my-own-modules/crowdsec-php-lib/scripts/public/geolocation-test.php -``` - We are using a Jest/Playwright Node.js stack to launch a suite of end-to-end tests. Tests code is in the `tests/end-to-end` folder. You should have to `chmod +x` the scripts you will find in diff --git a/scripts/auto-prepend/bounce.php b/scripts/auto-prepend/bounce.php index 31314c0..110709b 100644 --- a/scripts/auto-prepend/bounce.php +++ b/scripts/auto-prepend/bounce.php @@ -9,7 +9,7 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/settings.php'; -use CrowdSecBouncer\StandaloneBounce; +use CrowdSecBouncer\StandaloneBouncer; -$bounce = new StandaloneBounce(); -$bounce->safelyBounce($crowdSecStandaloneBouncerConfig); +$bouncer = new StandaloneBouncer($crowdSecStandaloneBouncerConfig); +$bouncer->safelyBounce(); diff --git a/scripts/auto-prepend/refresh-cache.php b/scripts/auto-prepend/refresh-cache.php index 6913910..fa91e05 100644 --- a/scripts/auto-prepend/refresh-cache.php +++ b/scripts/auto-prepend/refresh-cache.php @@ -9,10 +9,8 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/settings.php'; -use CrowdSecBouncer\StandaloneBounce; +use CrowdSecBouncer\StandaloneBouncer; -$bounce = new StandaloneBounce(); -$bounce->initLogger($crowdSecStandaloneBouncerConfig); -$bouncer = $bounce->init($crowdSecStandaloneBouncerConfig); +$bouncer = new StandaloneBouncer($crowdSecStandaloneBouncerConfig); $bouncer->refreshBlocklistCache(); -echo 'Cache has been refreshed' . \PHP_EOL; +echo 'Cache has been refreshed (if stream mode is enabled)' . \PHP_EOL; diff --git a/scripts/auto-prepend/settings.example.php b/scripts/auto-prepend/settings.example.php index 7dcf435..e7a37a7 100644 --- a/scripts/auto-prepend/settings.example.php +++ b/scripts/auto-prepend/settings.example.php @@ -104,13 +104,6 @@ */ 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, - /** Select from 'bypass' (minimum remediation),'captcha' or 'ban' (maximum remediation). - * Default to 'ban'. - * - * Cap the remediation to the selected one. - */ - 'max_remediation_level' => Constants::REMEDIATION_BAN, - /** If you use a CDN, a reverse proxy or a load balancer, set an array of IPs. * * For other IPs, the bouncer will not trust the X-Forwarded-For header. @@ -146,9 +139,6 @@ // Set the duration we keep in cache the captcha flow variables for an IP. In seconds. Defaults to 86400. 'captcha_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CAPTCHA, - // Set the duration we keep in cache a geolocation result for an IP . In seconds. Defaults to 86400. - 'geolocation_cache_duration' => Constants::CACHE_EXPIRATION_FOR_GEO, - /** true to enable stream mode, false to enable the live mode. Default to false. * * By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode @@ -168,11 +158,12 @@ 'enabled' => false, // Geolocation system. Only 'maxmind' is available for the moment. Default to 'maxmind' 'type' => Constants::GEOLOCATION_TYPE_MAXMIND, - /** true to store the geolocalized country in session. Default to true. - * - * Setting true will avoid multiple call to the geolocalized system (e.g. maxmind database) + /** + * This setting will be used to set the lifetime (in seconds) of a cached country + * associated to an IP. The purpose is to avoid multiple call to the geolocation system (e.g. maxmind database) + * . Default to 86400. Set 0 to disable caching. */ - 'save_result' => true, + 'cache_duration' => Constants::CACHE_EXPIRATION_FOR_GEO, // MaxMind settings 'maxmind' => [ /**Select from 'country' or 'city'. Default to 'country' @@ -185,31 +176,42 @@ ], ], + // Settings for ban and captcha walls + 'custom_css' => '', // true to hide CrowdSec mentions on ban and captcha walls. 'hide_mentions' => false, - - // Settings for ban and captcha walls - 'theme_color_text_primary' => 'black', - 'theme_color_text_secondary' => '#AAA', - 'theme_color_text_button' => 'white', - 'theme_color_text_error_message' => '#b90000', - 'theme_color_background_page' => '#eee', - 'theme_color_background_container' => 'white', - 'theme_color_background_button' => '#626365', - 'theme_color_background_button_hover' => '#333', - 'theme_custom_css' => '', - // Settings for captcha wall - 'theme_text_captcha_wall_tab_title' => 'Oops..', - 'theme_text_captcha_wall_title' => 'Hmm, sorry but...', - 'theme_text_captcha_wall_subtitle' => 'Please complete the security check.', - 'theme_text_captcha_wall_refresh_image_link' => 'refresh image', - 'theme_text_captcha_wall_captcha_placeholder' => 'Type here...', - 'theme_text_captcha_wall_send_button' => 'CONTINUE', - 'theme_text_captcha_wall_error_message' => 'Please try again.', - 'theme_text_captcha_wall_footer' => '', - // Settings for ban wall - 'theme_text_ban_wall_tab_title' => 'Oops..', - 'theme_text_ban_wall_title' => '🤭 Oh!', - 'theme_text_ban_wall_subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', - 'theme_text_ban_wall_footer' => '', + 'color' => [ + 'text' => [ + 'primary' => 'black', + 'secondary' => '#AAA', + 'button' => 'white', + 'error_message' => '#b90000', + ], + 'background' => [ + 'page' => '#eee', + 'container' => 'white', + 'button' => '#626365', + 'button_hover' => '#333', + ], + ], + 'text' => [ + // Settings for captcha wall + 'captcha_wall' => [ + 'tab_title' => 'Oops..', + 'title' => 'Hmm, sorry but...', + 'subtitle' => 'Please complete the security check.', + 'refresh_image_link' => 'refresh image', + 'captcha_placeholder' => 'Type here...', + 'send_button' => 'CONTINUE', + 'error_message' => 'Please try again.', + 'footer' => '', + ], + // Settings for ban wall + 'ban_wall' => [ + 'tab_title' => 'Oops..', + 'title' => '🤭 Oh!', + 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', + 'footer' => '', + ], + ], ]; diff --git a/scripts/clear-cache.php b/scripts/clear-cache.php index 955a617..0eba451 100644 --- a/scripts/clear-cache.php +++ b/scripts/clear-cache.php @@ -2,28 +2,20 @@ require_once __DIR__ . '/../vendor/autoload.php'; -use CrowdSecBouncer\Bouncer; +use CrowdSecBouncer\StandaloneBouncer; use Monolog\Formatter\LineFormatter; -use Monolog\Handler\RotatingFileHandler; use Monolog\Handler\StreamHandler; use Monolog\Logger; - - - // Parse arguments -$bouncerApiKey = $argv[1]; // required +$bouncerApiKey = $argv[1]??null; // required $apiUrl = $argv[3] ?: 'https://crowdsec:8080'; if (!$bouncerApiKey) { - echo 'Usage: php clear-cache.php '; - exit(1); + exit('Usage: php clear-cache.php '); } echo "\nClear the cache...\n"; -// Configure paths -$logPath = __DIR__.'/.crowdsec.log'; - // Instantiate the Stream logger $logger = new Logger('example'); @@ -32,9 +24,6 @@ $streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); $logger->pushHandler($streamHandler); -// Store logs with WARNING verbosity -$fileHandler = new RotatingFileHandler($logPath, 0, Logger::WARNING); -$logger->pushHandler($fileHandler); // Instantiate the bouncer $configs = [ @@ -42,7 +31,7 @@ 'api_url' => 'https://crowdsec:8080', 'fs_cache_path' => __DIR__ . '/.cache', ]; -$bouncer = new Bouncer($configs, $logger); +$bouncer = new StandaloneBouncer($configs, $logger); // Clear the cache. $bouncer->clearCache(); diff --git a/scripts/full-example-live-mode.php b/scripts/full-example-live-mode.php deleted file mode 100644 index 0cb10c4..0000000 --- a/scripts/full-example-live-mode.php +++ /dev/null @@ -1,56 +0,0 @@ - []'; - exit(1); -} -echo "\nVerify $requestedIp with $apiUrl...\n"; - -// Configure paths -$logPath = __DIR__.'/crowdsec.log'; - -// Instantiate the Stream logger -$logger = new Logger('example'); - -// Display logs with DEBUG verbosity -$streamHandler = new StreamHandler('php://stdout', Logger::DEBUG); -$streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); -$logger->pushHandler($streamHandler); - -// Store logs with WARNING verbosity -$fileHandler = new RotatingFileHandler($logPath, 0, Logger::WARNING); -$logger->pushHandler($fileHandler); - -// Instantiate the bouncer -$configs = [ - 'api_key' => $bouncerApiKey, - 'api_url' => $apiUrl, - 'api_user_agent' => 'MyCMS CrowdSec Bouncer/1.0.0', - 'api_timeout' => 1, - 'stream_mode' => false, - 'max_remediation_level' => 'ban', - 'clean_ip_cache_duration' => 300, - 'bad_ip_cache_duration' => 30, - 'fs_cache_path' => __DIR__ . '/../.cache' -]; -$bouncer = new Bouncer($configs, $logger); - - -// Ask remediation to LAPI -$remediation = $bouncer->getRemediationForIp($requestedIp); - -// "ban", "captcha" or "bypass" -echo "\nResult: $remediation\n\n"; diff --git a/tests/end-to-end/php-scripts/cache-actions.php.dist b/scripts/public/cache-actions.php similarity index 60% rename from tests/end-to-end/php-scripts/cache-actions.php.dist rename to scripts/public/cache-actions.php index 7d7cd80..76cec4f 100644 --- a/tests/end-to-end/php-scripts/cache-actions.php.dist +++ b/scripts/public/cache-actions.php @@ -1,20 +1,19 @@ initLogger($crowdSecStandaloneBouncerConfig); - $bouncer = $bounce->init($crowdSecStandaloneBouncerConfig); + $bouncer = new StandaloneBouncer($crowdSecStandaloneBouncerConfig); + switch ($action) { case 'refresh': $bouncer->refreshBlocklistCache(); @@ -25,9 +24,6 @@ case 'prune': $bouncer->pruneCache(); break; - case 'warm-up': - $bouncer->warmBlocklistCacheUp(); - break; default: throw new Exception("Unknown cache action type:$action"); } @@ -46,9 +42,5 @@ "; } else { - die('You must pass an "action" param (refresh or clear)' . PHP_EOL); + exit('You must pass an "action" param (refresh, clear or prune)' . \PHP_EOL); } - - - - diff --git a/tests/end-to-end/php-scripts/geolocation-test.php.dist b/scripts/public/geolocation-test.php similarity index 60% rename from tests/end-to-end/php-scripts/geolocation-test.php.dist rename to scripts/public/geolocation-test.php index f744b44..664b8b2 100644 --- a/tests/end-to-end/php-scripts/geolocation-test.php.dist +++ b/scripts/public/geolocation-test.php @@ -3,47 +3,46 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../auto-prepend/settings.php'; -use CrowdSecBouncer\StandaloneBounce; -use CrowdSecBouncer\Geolocation; - +use CrowdSec\RemediationEngine\Geolocation; +use CrowdSecBouncer\StandaloneBouncer; +/** + * @var $crowdSecStandaloneBouncerConfig + */ if (isset($_GET['ip'])) { $requestedIp = $_GET['ip']; $dbName = $_GET['db-name'] ?? 'GeoLite2-Country.mmdb'; $dbType = $_GET['db-type'] ?? 'country'; - $saveResult = isset($_GET['save-result']); + $cacheDuration = isset($_GET['cache-duration']) ? (int) $_GET['cache-duration'] : 0; $fakeBrokenDb = isset($_GET['broken-db']); $geolocConfig = [ 'enabled' => true, - 'save_result' => $saveResult, + 'cache_duration' => $cacheDuration, 'type' => 'maxmind', 'maxmind' => [ 'database_type' => $dbType, - 'database_path' => '/var/www/html/my-own-modules/crowdsec-php-lib/tests/' . $dbName - ] + 'database_path' => '/var/www/html/my-own-modules/crowdsec-php-lib/tests/' . $dbName, + ], ]; - if($fakeBrokenDb){ + if ($fakeBrokenDb) { $geolocConfig['maxmind']['database_path'] = '/var/www/html/my-own-modules/crowdsec-php-lib/tests/broken.mmdb'; } - $bounce = new StandaloneBounce(); - /** @var $crowdSecStandaloneBouncerConfig */ $finalConfig = array_merge($crowdSecStandaloneBouncerConfig, ['geolocation' => $geolocConfig]); - $bounce->initLogger($finalConfig); - $bouncer = $bounce->init($finalConfig); - $apiCache = $bouncer->getApiCache(); + $bouncer = new StandaloneBouncer($finalConfig); + + $cache = $bouncer->getRemediationEngine()->getCacheStorage(); - $geolocation = new Geolocation(); - if(!$saveResult){ - $geolocation->clearGeolocationCache($requestedIp, $apiCache); + $geolocation = new Geolocation($geolocConfig, $cache, $bouncer->getLogger()); + if ($cacheDuration <= 0) { + $geolocation->clearGeolocationCache($requestedIp); } - $countryResult = $geolocation->getCountryResult($geolocConfig, $requestedIp, $apiCache); + $countryResult = $geolocation->handleCountryResultForIp($requestedIp); $country = $countryResult['country']; $notFound = $countryResult['not_found']; $error = $countryResult['error']; - $saveMessage = $saveResult ? 'true' : 'false'; echo " @@ -59,15 +58,11 @@
  • Country: $country
  • Not Found message: $notFound
  • Error message: $error
  • -
  • Save result: $saveMessage
  • +
  • Cache duration: $cacheDuration
  • "; } else { - die('You must pass an "ip" param' . PHP_EOL); + exit('You must pass an "ip" param' . \PHP_EOL); } - - - - diff --git a/scripts/refresh-cache-stream.php b/scripts/refresh-cache-stream.php new file mode 100644 index 0000000..43f21c5 --- /dev/null +++ b/scripts/refresh-cache-stream.php @@ -0,0 +1,35 @@ +'); +} + +// Instantiate the Stream logger +$logger = new Logger('example'); + +// Display logs with DEBUG verbosity +$streamHandler = new StreamHandler('php://stdout', Logger::DEBUG); +$streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); +$logger->pushHandler($streamHandler); + +// Instantiate the bouncer +$configs = [ + 'api_key' => $bouncerKey, + 'api_url' => 'https://crowdsec:8080', + 'fs_cache_path' => __DIR__ . '/.cache', + 'stream_mode' => true, +]; +$bouncer = new StandaloneBouncer($configs, $logger); + +// Refresh the blocklist cache +$bouncer->refreshBlocklistCache(); +echo "Cache successfully refreshed.\n"; diff --git a/scripts/refresh-cache.php b/scripts/refresh-cache.php deleted file mode 100644 index 6121c5c..0000000 --- a/scripts/refresh-cache.php +++ /dev/null @@ -1,32 +0,0 @@ -'); -} - -// Instantiate the Stream logger with warning level -$logger = new Logger('example'); -$fileHandler = new RotatingFileHandler(__DIR__ . '/crowdsec.log', 0, Logger::WARNING); -$logger->pushHandler($fileHandler); - -// Instantiate the bouncer -$configs = [ - 'api_key' => $bouncerKey, - 'api_url' => 'https://crowdsec:8080', - 'fs_cache_path' => __DIR__ . '/.cache' -]; -$bouncer = new Bouncer($configs, $logger); - -// Refresh the blocklist cache -$bouncer->refreshBlocklistCache(); -echo "Cache successfully refreshed.\n"; diff --git a/scripts/check-ip.php b/scripts/standalone-check-ip-live.php similarity index 69% rename from scripts/check-ip.php rename to scripts/standalone-check-ip-live.php index 4ed6763..214d865 100644 --- a/scripts/check-ip.php +++ b/scripts/standalone-check-ip-live.php @@ -2,19 +2,18 @@ require_once __DIR__ . '/../vendor/autoload.php'; -use CrowdSecBouncer\Bouncer; +use CrowdSecBouncer\StandaloneBouncer; use Monolog\Formatter\LineFormatter; -use Monolog\Handler\RotatingFileHandler; use Monolog\Handler\StreamHandler; use Monolog\Logger; // Parse argument -$requestedIp = $argv[1]; -$bouncerKey = $argv[2]; +$requestedIp = $argv[1]??null; +$bouncerKey = $argv[2]??null; if (!$requestedIp || !$bouncerKey) { - die('Usage: php check-ip.php '); + exit('Usage: php standalone-check-ip-live.php '); } // Instantiate the Stream logger $logger = new Logger('example'); @@ -24,17 +23,14 @@ $streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); $logger->pushHandler($streamHandler); -// Store logs with WARNING verbosity -$fileHandler = new RotatingFileHandler(__DIR__ . '/crowdsec.log', 0, Logger::WARNING); -$logger->pushHandler($fileHandler); - // Init $configs = [ 'api_key' => $bouncerKey, 'api_url' => 'https://crowdsec:8080', 'fs_cache_path' => __DIR__ . '/.cache', + 'stream_mode' => false ]; -$bouncer = new Bouncer($configs, $logger); +$bouncer = new StandaloneBouncer($configs, $logger); // Ask remediation to LAPI diff --git a/src/AbstractBounce.php b/src/AbstractBounce.php deleted file mode 100644 index 8a00703..0000000 --- a/src/AbstractBounce.php +++ /dev/null @@ -1,494 +0,0 @@ -bouncer) { - throw new BouncerException('Bouncer must be instantiated to get cache data.'); - } - $apiCache = $this->bouncer->getApiCache(); - - return $apiCache->getIpVariables($cacheTag, $names, $ip); - } - - /** - * Run a bounce. - * - * @return void - * @throws CacheException - * @throws InvalidArgumentException|BouncerException - */ - public function run(): void - { - $this->bounceCurrentIp(); - } - - /** - * Set a ip variable. - * - * @param string $cacheTag - * @param array $pairs - * @param string $ip - * @return void - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - */ - public function setIpVariables(string $cacheTag, array $pairs, string $ip): void - { - if (!$this->bouncer) { - throw new BouncerException('Bouncer must be instantiated to set cache data.'); - } - $apiCache = $this->bouncer->getApiCache(); - $apiCache->setIpVariables($cacheTag, $pairs, $ip); - } - - /** - * Unset ip variables. - * - * @param string $cacheTag - * @param array $names - * @param string $ip - * @return void - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - */ - public function unsetIpVariables(string $cacheTag, array $names, string $ip): void - { - if (!$this->bouncer) { - throw new BouncerException('Bouncer must be instantiated to unset cache data.'); - } - $apiCache = $this->bouncer->getApiCache(); - $apiCache->unsetIpVariables($cacheTag, $names, $ip); - } - - /** - * Bounce process - * - * @return void - * @throws BouncerException - * @throws CacheException - * @throws InvalidArgumentException - */ - protected function bounceCurrentIp(): void - { - if (!$this->bouncer) { - throw new BouncerException('Bouncer must be instantiated to bounce an IP.'); - } - $configs = $this->bouncer->getConfigs(); - // Retrieve the current IP (even if it is a proxy IP) or a testing IP - $ip = !empty($configs['forced_test_ip']) ? $configs['forced_test_ip'] : $this->getRemoteIp(); - $ip = $this->handleForwardedFor($ip, $configs); - $remediation = $this->bouncer->getRemediationForIp($ip); - $this->handleRemediation($remediation, $ip); - } - - /** - * @throws InvalidArgumentException|BouncerException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - protected function displayCaptchaWall(string $ip): void - { - $options = $this->getCaptchaWallOptions(); - $captchaVariables = $this->getIpVariables( - Constants::CACHE_TAG_CAPTCHA, - ['crowdsec_captcha_resolution_failed', 'crowdsec_captcha_inline_image'], - $ip - ); - $body = Bouncer::getCaptchaHtmlTemplate( - (bool)$captchaVariables['crowdsec_captcha_resolution_failed'], - (string)$captchaVariables['crowdsec_captcha_inline_image'], - '', - $options - ); - $this->sendResponse($body, 401); - } - - protected function getArraySettings(string $name): array - { - return !empty($this->settings[$name]) ? (array)$this->settings[$name] : []; - } - - protected function getBoolSettings(string $name): bool - { - return !empty($this->settings[$name]); - } - - protected function getIntegerSettings(string $name): int - { - return !empty($this->settings[$name]) ? (int)$this->settings[$name] : 0; - } - - protected function getStringSettings(string $name): string - { - return !empty($this->settings[$name]) ? (string)$this->settings[$name] : ''; - } - - /** - * @return void - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - protected function handleBanRemediation(): void - { - $options = $this->getBanWallOptions(); - $body = Bouncer::getAccessForbiddenHtmlTemplate($options); - $this->sendResponse($body, 403); - } - - /** - * @param string $ip - * - * @return void - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - protected function handleCaptchaRemediation(string $ip) - { - // Check captcha resolution form - $this->handleCaptchaResolutionForm($ip); - $cachedCaptchaVariables = $this->getIpVariables( - Constants::CACHE_TAG_CAPTCHA, - ['crowdsec_captcha_has_to_be_resolved'], - $ip - ); - $mustResolve = false; - if (null === $cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved']) { - // Set up the first captcha remediation. - $mustResolve = true; - $captchaCouple = Bouncer::buildCaptchaCouple(); - $captchaVariables = [ - 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], - 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], - 'crowdsec_captcha_has_to_be_resolved' => true, - 'crowdsec_captcha_resolution_failed' => false, - 'crowdsec_captcha_resolution_redirect' => 'POST' === $this->getHttpMethod() && - !empty($_SERVER['HTTP_REFERER']) - ? $_SERVER['HTTP_REFERER'] : '/', - ]; - $this->setIpVariables(Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip); - } - - // Display captcha page if this is required. - if ($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'] || $mustResolve) { - $this->displayCaptchaWall($ip); - } - } - - /** - * @param string $ip - * @return void - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - protected function handleCaptchaResolutionForm(string $ip): void - { - $cachedCaptchaVariables = $this->getIpVariables( - Constants::CACHE_TAG_CAPTCHA, - [ - 'crowdsec_captcha_has_to_be_resolved', - 'crowdsec_captcha_phrase_to_guess', - 'crowdsec_captcha_resolution_redirect', - ], - $ip - ); - if ($this->shouldEarlyReturn($cachedCaptchaVariables, $ip)) { - return; - } - - // Handle a captcha resolution try - if ( - null !== $this->getPostedVariable('phrase') - && null !== $cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'] - ) { - if (!$this->bouncer) { - throw new BouncerException('Bouncer must be instantiated to check captcha.'); - } - if ( - $this->bouncer->checkCaptcha( - (string)$cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'], - (string)$this->getPostedVariable('phrase'), - $ip - ) - ) { - // User has correctly filled the captcha - $this->setIpVariables( - Constants::CACHE_TAG_CAPTCHA, - ['crowdsec_captcha_has_to_be_resolved' => false], - $ip - ); - $unsetVariables = [ - 'crowdsec_captcha_phrase_to_guess', - 'crowdsec_captcha_inline_image', - 'crowdsec_captcha_resolution_failed', - 'crowdsec_captcha_resolution_redirect', - ]; - $this->unsetIpVariables(Constants::CACHE_TAG_CAPTCHA, $unsetVariables, $ip); - $redirect = $cachedCaptchaVariables['crowdsec_captcha_resolution_redirect'] ?? '/'; - header("Location: $redirect"); - exit(0); - } else { - // The user failed to resolve the captcha. - $this->setIpVariables( - Constants::CACHE_TAG_CAPTCHA, - ['crowdsec_captcha_resolution_failed' => true], - $ip - ); - } - } - } - - /** - * Handle X-Forwarded-For HTTP header to retrieve the IP to bounce - * - * @param string $ip - * @param array $configs - * @return string - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - protected function handleForwardedFor(string $ip, array $configs): string - { - $forwardedIp = null; - if (empty($configs['forced_test_forwarded_ip'])) { - $xForwardedForHeader = $this->getHttpRequestHeader('X-Forwarded-For'); - if (null !== $xForwardedForHeader) { - $ipList = array_map('trim', array_values(array_filter(explode(',', $xForwardedForHeader)))); - $forwardedIp = end($ipList); - } - } elseif ($configs['forced_test_forwarded_ip'] === Constants::X_FORWARDED_DISABLED) { - if ($this->logger) { - $this->logger->debug('', [ - 'type' => 'DISABLED_X_FORWARDED_FOR_USAGE', - 'original_ip' => $ip, - ]); - } - } else { - $forwardedIp = (string) $configs['forced_test_forwarded_ip']; - } - - if (is_string($forwardedIp) && $this->shouldTrustXforwardedFor($ip)) { - $ip = $forwardedIp; - } elseif ($this->logger) { - $this->logger->warning('', [ - 'type' => 'NON_AUTHORIZED_X_FORWARDED_FOR_USAGE', - 'original_ip' => $ip, - 'x_forwarded_for_ip' => is_string($forwardedIp) ? $forwardedIp : 'type not as expected', - ]); - } - return $ip; - } - - /** - * Handle remediation for some IP. - * - * @param string $remediation - * @param string $ip - * @return void - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - */ - protected function handleRemediation(string $remediation, string $ip) - { - switch ($remediation) { - case Constants::REMEDIATION_CAPTCHA: - $this->handleCaptchaRemediation($ip); - break; - case Constants::REMEDIATION_BAN: - $this->handleBanRemediation(); - break; - case Constants::REMEDIATION_BYPASS: - default: - } - } - - /** - * @param array $configs - * @param string $loggerName - * @return void - */ - protected function initLoggerHelper(array $configs, string $loggerName): void - { - $this->logger = new Logger($loggerName); - $logDir = $configs['log_directory_path'] ?? __DIR__ . '/.logs'; - if (empty($configs['disable_prod_log'])) { - $logPath = $logDir . '/prod.log'; - $fileHandler = new RotatingFileHandler($logPath, 0, Logger::INFO); - $fileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%context%\n")); - $this->logger->pushHandler($fileHandler); - } - - // Set custom readable logger when debug=true - if (!empty($configs['debug_mode'])) { - $debugLogPath = $logDir . '/debug.log'; - $debugFileHandler = new RotatingFileHandler($debugLogPath, 0, Logger::DEBUG); - $debugFileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%context%\n")); - $this->logger->pushHandler($debugFileHandler); - } - } - - /** - * @throws BouncerException - */ - protected function prepareBouncerConfigs(): array - { - $bouncingLevel = $this->getStringSettings('bouncing_level'); - $apiTimeout = $this->getIntegerSettings('api_timeout'); - - // Compute max remediation level - switch ($bouncingLevel) { - case Constants::BOUNCING_LEVEL_DISABLED: - $maxRemediationLevel = Constants::REMEDIATION_BYPASS; - break; - case Constants::BOUNCING_LEVEL_FLEX: - $maxRemediationLevel = Constants::REMEDIATION_CAPTCHA; - break; - case Constants::BOUNCING_LEVEL_NORMAL: - $maxRemediationLevel = Constants::REMEDIATION_BAN; - break; - default: - throw new BouncerException("Unknown $bouncingLevel"); - } - $authType = $this->getStringSettings('auth_type'); - - return [ - // LAPI connection - 'auth_type' => $authType ?: Constants::AUTH_KEY, - 'tls_cert_path' => $this->getStringSettings('tls_cert_path'), - 'tls_key_path' => $this->getStringSettings('tls_key_path'), - 'tls_verify_peer' => $this->getBoolSettings('tls_verify_peer'), - 'tls_ca_cert_path' => $this->getStringSettings('tls_ca_cert_path'), - 'api_key' => $this->getStringSettings('api_key'), - 'api_url' => $this->getStringSettings('api_url'), - 'api_user_agent' => $this->getStringSettings('api_user_agent'), - 'api_timeout' => $apiTimeout !== 0 ? $apiTimeout : Constants::API_TIMEOUT, - 'use_curl' => $this->getBoolSettings('use_curl'), - // Debug - 'debug_mode' => $this->getBoolSettings('debug_mode'), - 'log_directory_path' => $this->getStringSettings('log_directory_path'), - 'forced_test_ip' => $this->getStringSettings('forced_test_ip'), - 'forced_test_forwarded_ip' => $this->getStringSettings('forced_test_forwarded_ip'), - 'display_errors' => $this->getBoolSettings('display_errors'), - // Bouncer - 'bouncing_level' => $bouncingLevel, - 'trust_ip_forward_array' => $this->getArraySettings('trust_ip_forward_array'), - 'fallback_remediation' => $this->getStringSettings('fallback_remediation'), - 'max_remediation_level' => $maxRemediationLevel, - // Cache settings - 'stream_mode' => $this->getBoolSettings('stream_mode'), - 'cache_system' => $this->getStringSettings('cache_system'), - 'fs_cache_path' => $this->getStringSettings('fs_cache_path'), - 'redis_dsn' => $this->getStringSettings('redis_dsn'), - 'memcached_dsn' => $this->getStringSettings('memcached_dsn'), - 'clean_ip_cache_duration' => $this->getIntegerSettings('clean_ip_cache_duration'), - 'bad_ip_cache_duration' => $this->getIntegerSettings('bad_ip_cache_duration'), - 'captcha_cache_duration' => $this->getIntegerSettings('captcha_cache_duration'), - 'geolocation_cache_duration' => $this->getIntegerSettings('geolocation_cache_duration'), - // Geolocation - 'geolocation' => $this->getArraySettings('geolocation'), - ]; - } - - protected function shouldTrustXforwardedFor(string $ip): bool - { - $parsedAddress = Factory::parseAddressString($ip, 3); - if (null === $parsedAddress) { - if ($this->logger) { - $this->logger->warning('', [ - 'type' => 'INVALID_INPUT_IP', - 'ip' => $ip, - ]); - } - - return false; - } - $comparableAddress = $parsedAddress->getComparableString(); - - foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) { - if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) { - return true; - } - } - - return false; - } - - /** - * @param array $cachedCaptchaVariables - * @param string $ip - * @return bool - * @throws BouncerException - * @throws CacheException - * @throws InvalidArgumentException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): bool - { - $result = false; - if (\in_array($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'], [null, false])) { - // Early return if no captcha has to be resolved. - $result = true; - } elseif ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) { - // Early return if no form captcha form has been filled. - $result = true; - } elseif (null !== $this->getPostedVariable('refresh') && (int)$this->getPostedVariable('refresh')) { - // Handle image refresh. - // Generate new captcha image for the user - $captchaCouple = Bouncer::buildCaptchaCouple(); - $captchaVariables = [ - 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], - 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], - 'crowdsec_captcha_resolution_failed' => false, - ]; - $this->setIpVariables(Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip); - - $result = true; - } - - return $result; - } -} diff --git a/src/AbstractBouncer.php b/src/AbstractBouncer.php new file mode 100644 index 0000000..ad8b52f --- /dev/null +++ b/src/AbstractBouncer.php @@ -0,0 +1,687 @@ +pushHandler(new NullHandler()); + } + $this->logger = $logger; + $this->configure($configs); + $this->remediationEngine = $remediationEngine; + + $configs = $this->getConfigs(); + // Clean configs for lighter log + unset($configs['text'], $configs['color']); + $this->logger->debug('Instantiate bouncer', [ + 'type' => 'BOUNCER_INIT', + 'logger' => \get_class($this->getLogger()), + 'remediation' => \get_class($this->getRemediationEngine()), + 'configs' => $configs + ]); + } + + /** + * Build a captcha couple. + * + * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" + * representing the image data + */ + public static function buildCaptchaCouple(): array + { + $captchaBuilder = new CaptchaBuilder(); + + return [ + 'phrase' => $captchaBuilder->getPhrase(), + 'inlineImage' => $captchaBuilder->build()->inline(), + ]; + } + + /** + * This method clear the full data in cache. + * + * @return bool If the cache has been successfully cleared or not + * + */ + public function clearCache(): bool + { + return $this->getRemediationEngine()->clearCache(); + } + + /** + * Retrieve Bouncer configuration by name + * + * @param string $name + * @return mixed + */ + public function getConfig(string $name) + { + return (isset($this->configs[$name])) ? $this->configs[$name] : null; + } + + /** + * Retrieve Bouncer configurations + * + * @return array + */ + public function getConfigs(): array + { + return $this->configs; + } + + /** + * Return cached variables associated to an IP. + * + * @param string $cacheTag + * @param array $names + * @param string $ip + * @return array + */ + public function getIpVariables(string $prefix, array $names, string $ip): array + { + $cache = $this->getCache(); + + return $cache->getIpVariables($prefix, $names, $ip); + } + + /** + * Returns the logger instance. + * + * @return LoggerInterface the logger used by this library + */ + public function getLogger(): LoggerInterface + { + return $this->logger; + } + + public function getRemediationEngine(): AbstractRemediation + { + return $this->remediationEngine; + } + + /** + * Get the remediation for the specified IP. This method use the cache layer. + * In live mode, when no remediation was found in cache, + * the cache system will call LAPI to check if there is a decision. + * + * @param string $ip The IP to check + * + * @return string the remediation to apply (ex: 'ban', 'captcha', 'bypass') + * + */ + public function getRemediationForIp(string $ip): string + { + return $this->capRemediationLevel($this->getRemediationEngine()->getIpRemediation($ip)); + } + + /** + * This method prune the cache: it removes all the expired cache items. + * + * @return bool If the cache has been successfully pruned or not + */ + public function pruneCache(): bool + { + return $this->getRemediationEngine()->pruneCache(); + } + + /** + * Used in stream mode only. + * This method should be called periodically (ex: crontab) in an asynchronous way to update the bouncer cache. + * + * @return array Number of deleted and new decisions + * + */ + public function refreshBlocklistCache(): array + { + return $this->getRemediationEngine()->refreshDecisions(); + } + + /** + * Set a ip variable. + * + * @param string $cacheScope + * @param array $pairs + * @param string $ip + * @return void + */ + public function setIpVariables(string $cacheScope, array $pairs, string $ip, int $duration, string $cacheTag = + ''): void + { + $cache = $this->getCache(); + $cache->setIpVariables($cacheScope, $pairs, $ip, $duration, $cacheTag); + } + + /** + * Unset ip variables. + * + * @param string $cacheTag + * @param array $names + * @param string $ip + * @return void + */ + public function unsetIpVariables(string $cacheScope, array $names, string $ip, int $duration, string $cacheTag = ''): void + { + $cache = $this->getCache(); + $cache->unsetIpVariables($cacheScope, $names, $ip, $duration, $cacheTag); + } + + /** + * Bounce process + * + * @return void + */ + protected function bounceCurrentIp(): void + { + // Retrieve the current IP (even if it is a proxy IP) or a testing IP + $forcedTestIp = $this->getStringConfig('forced_test_ip'); + $ip = !empty($forcedTestIp) ? $forcedTestIp : $this->getRemoteIp(); + $ip = $this->handleForwardedFor($ip, $this->configs); + $remediation = $this->getRemediationForIp($ip); + $this->handleRemediation($remediation, $ip); + } + + /** + * Check if the captcha filled by the user is correct or not. + * We are permissive with the user (0 is interpreted as "o" and 1 in interpreted as "l"). + * + * @param string $expected The expected phrase + * @param string $try The phrase to check (the user input) + * @param string $ip The IP of the use (for logging purpose) + * + * @return bool If the captcha input was correct or not + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + protected function checkCaptcha(string $expected, string $try, string $ip): bool + { + $solved = PhraseBuilder::comparePhrases($expected, $try); + $this->logger->info('Captcha has been solved', [ + 'type' => 'CAPTCHA_SOLVED', + 'ip' => $ip, + 'resolution' => $solved, + ]); + + return $solved; + } + + /** + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + protected function displayCaptchaWall(string $ip): void + { + $captchaVariables = $this->getIpVariables( + Constants::CACHE_TAG_CAPTCHA, + ['crowdsec_captcha_resolution_failed', 'crowdsec_captcha_inline_image'], + $ip + ); + $body = $this->getCaptchaHtmlTemplate( + (bool)$captchaVariables['crowdsec_captcha_resolution_failed'], + (string)$captchaVariables['crowdsec_captcha_inline_image'], + '' + ); + $this->sendResponse($body, 401); + } + + /** + * Returns a default "CrowdSec 403" HTML template. + * The input $config should match the TemplateConfiguration input format. + * + * + * @return string The HTML compiled template + */ + protected function getAccessForbiddenHtmlTemplate(): string + { + $template = new Template('ban.html.twig'); + + return $template->render($this->configs); + } + + protected function getArrayConfig(string $name): array + { + return !empty($this->configs[$name]) ? (array)$this->configs[$name] : []; + } + + protected function getBoolConfig(string $name): bool + { + return !empty($this->configs[$name]); + } + + /** + * Returns a default "CrowdSec Captcha (401)" HTML template. + * + * @param bool $error + * @param string $captchaImageSrc + * @param string $captchaResolutionFormUrl + * @return string + */ + protected function getCaptchaHtmlTemplate( + bool $error, + string $captchaImageSrc, + string $captchaResolutionFormUrl + ): string { + $template = new Template('captcha.html.twig'); + + return $template->render(array_merge( + $this->configs, + [ + 'error' => $error, + 'captcha_img' => $captchaImageSrc, + 'captcha_resolution_url' => $captchaResolutionFormUrl + ] + )); + } + + protected function getIntegerConfig(string $name): int + { + return !empty($this->configs[$name]) ? (int)$this->configs[$name] : 0; + } + + protected function getStringConfig(string $name): string + { + return !empty($this->configs[$name]) ? (string)$this->configs[$name] : ''; + } + + /** + * @return array [[string, string], ...] Returns IP ranges to trust as proxies as an array of comparables ip bounds + */ + protected function getTrustForwardedIpBoundsList(): array + { + return $this->getArrayConfig('trust_ip_forward_array'); + } + + /** + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + protected function handleBanRemediation(): void + { + $body = $this->getAccessForbiddenHtmlTemplate(); + $this->sendResponse($body, 403); + } + + protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache{ + + $cacheSystem = $configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; + switch ($cacheSystem) { + case Constants::CACHE_SYSTEM_PHPFS: + $cache = new PhpFiles($configs, $logger); + break; + case Constants::CACHE_SYSTEM_MEMCACHED: + $cache = new Memcached($configs, $logger); + break; + case Constants::CACHE_SYSTEM_REDIS: + $cache = new Redis($configs, $logger); + break; + default: + throw new BouncerException("Unknown selected cache technology: $cacheSystem"); + } + + return $cache; + } + + /** + * @param string $ip + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + protected function handleCaptchaRemediation(string $ip) + { + // Check captcha resolution form + $this->handleCaptchaResolutionForm($ip); + $cachedCaptchaVariables = $this->getIpVariables( + Constants::CACHE_TAG_CAPTCHA, + ['crowdsec_captcha_has_to_be_resolved'], + $ip + ); + $mustResolve = false; + if (null === $cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved']) { + // Set up the first captcha remediation. + $mustResolve = true; + $captchaCouple = $this->buildCaptchaCouple(); + $captchaVariables = [ + 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], + 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], + 'crowdsec_captcha_has_to_be_resolved' => true, + 'crowdsec_captcha_resolution_failed' => false, + 'crowdsec_captcha_resolution_redirect' => 'POST' === $this->getHttpMethod() && + !empty($_SERVER['HTTP_REFERER']) + ? $_SERVER['HTTP_REFERER'] : '/', + ]; + $duration = $this->getIntegerConfig('captcha_cache_duration'); + $this->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + ); + } + + // Display captcha page if this is required. + if ($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'] || $mustResolve) { + $this->displayCaptchaWall($ip); + } + } + + /** + * @param string $ip + * @return void + * + * @SuppressWarnings(PHPMD.ElseExpression) + */ + protected function handleCaptchaResolutionForm(string $ip): void + { + $cachedCaptchaVariables = $this->getIpVariables( + Constants::CACHE_TAG_CAPTCHA, + [ + 'crowdsec_captcha_has_to_be_resolved', + 'crowdsec_captcha_phrase_to_guess', + 'crowdsec_captcha_resolution_redirect', + ], + $ip + ); + if ($this->shouldEarlyReturn($cachedCaptchaVariables, $ip)) { + return; + } + + // Handle a captcha resolution try + if ( + null !== $this->getPostedVariable('phrase') + && null !== $cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'] + ) { + $duration = $this->getIntegerConfig('captcha_cache_duration'); + if ( + $this->checkCaptcha( + (string)$cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'], + (string)$this->getPostedVariable('phrase'), + $ip + ) + ) { + // User has correctly filled the captcha + $this->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, + ['crowdsec_captcha_has_to_be_resolved' => false], + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA + ); + $unsetVariables = [ + 'crowdsec_captcha_phrase_to_guess', + 'crowdsec_captcha_inline_image', + 'crowdsec_captcha_resolution_failed', + 'crowdsec_captcha_resolution_redirect', + ]; + $this->unsetIpVariables( + Constants::CACHE_TAG_CAPTCHA, $unsetVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + ); + $redirect = $cachedCaptchaVariables['crowdsec_captcha_resolution_redirect'] ?? '/'; + header("Location: $redirect"); + exit(0); + } else { + // The user failed to resolve the captcha. + $this->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, + ['crowdsec_captcha_resolution_failed' => true], + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA + ); + } + } + } + + protected function handleClient(array $configs, LoggerInterface $logger) + { + $requestHandler = empty($configs['use_curl']) ? new FileGetContents($configs) : new Curl($configs); + + return new BouncerClient($configs, $requestHandler, $logger); + } + + /** + * Handle X-Forwarded-For HTTP header to retrieve the IP to bounce + * + * @param string $ip + * @param array $configs + * @return string + * + * @SuppressWarnings(PHPMD.ElseExpression) + */ + protected function handleForwardedFor(string $ip, array $configs): string + { + $forwardedIp = null; + if (empty($configs['forced_test_forwarded_ip'])) { + $xForwardedForHeader = $this->getHttpRequestHeader('X-Forwarded-For'); + if (null !== $xForwardedForHeader) { + $ipList = array_map('trim', array_values(array_filter(explode(',', $xForwardedForHeader)))); + $forwardedIp = end($ipList); + } + } elseif ($configs['forced_test_forwarded_ip'] === Constants::X_FORWARDED_DISABLED) { + $this->logger->debug('X-Forwarded-for usage is disabled', [ + 'type' => 'DISABLED_X_FORWARDED_FOR_USAGE', + 'original_ip' => $ip, + ]); + } else { + $forwardedIp = (string) $configs['forced_test_forwarded_ip']; + } + + if (is_string($forwardedIp) && $this->shouldTrustXforwardedFor($ip)) { + $ip = $forwardedIp; + } else { + $this->logger->warning('Detected IP is not allowed for X-Forwarded-for usage', [ + 'type' => 'NON_AUTHORIZED_X_FORWARDED_FOR_USAGE', + 'original_ip' => $ip, + 'x_forwarded_for_ip' => is_string($forwardedIp) ? $forwardedIp : 'type not as expected', + ]); + } + return $ip; + } + + /** + * Handle remediation for some IP. + * + * @param string $remediation + * @param string $ip + * @return void + */ + protected function handleRemediation(string $remediation, string $ip) + { + switch ($remediation) { + case Constants::REMEDIATION_CAPTCHA: + $this->handleCaptchaRemediation($ip); + break; + case Constants::REMEDIATION_BAN: + $this->handleBanRemediation(); + break; + case Constants::REMEDIATION_BYPASS: + default: + } + } + + /** + * @param array $configs + * @param string $loggerName + * @return void + */ + protected function initFileLogger(array $configs, string $loggerName): LoggerInterface + { + $logger = new Logger($loggerName); + $logDir = $configs['log_directory_path'] ?? __DIR__ . '/.logs'; + if (empty($configs['disable_prod_log'])) { + $logPath = $logDir . '/prod.log'; + $fileHandler = new RotatingFileHandler($logPath, 0, Logger::INFO); + $fileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%message%|%context%\n")); + $logger->pushHandler($fileHandler); + } + + // Set custom readable logger when debug=true + if (!empty($configs['debug_mode'])) { + $debugLogPath = $logDir . '/debug.log'; + $debugFileHandler = new RotatingFileHandler($debugLogPath, 0, Logger::DEBUG); + $debugFileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%message%|%context%\n")); + $logger->pushHandler($debugFileHandler); + } + + return $logger; + } + + protected function shouldTrustXforwardedFor(string $ip): bool + { + $parsedAddress = Factory::parseAddressString($ip, 3); + if (null === $parsedAddress) { + $this->logger->warning('IP is invalid', [ + 'type' => 'INVALID_INPUT_IP', + 'ip' => $ip, + ]); + + return false; + } + $comparableAddress = $parsedAddress->getComparableString(); + + foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) { + if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) { + return true; + } + } + + return false; + } + + /** + * Cap the remediation to a fixed value given in configuration. + * + * @param string $remediation The maximum remediation that can ban applied (ex: 'ban', 'captcha', 'bypass') + * + * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') + */ + private function capRemediationLevel(string $remediation): string + { + $orderedRemediations = $this->getRemediationEngine()->getConfig('ordered_remediations')??[]; + + $bouncingLevel = $this->getStringConfig('bouncing_level')??Constants::BOUNCING_LEVEL_NORMAL; + // Compute max remediation level + switch ($bouncingLevel) { + case Constants::BOUNCING_LEVEL_DISABLED: + $maxRemediationLevel = Constants::REMEDIATION_BYPASS; + break; + case Constants::BOUNCING_LEVEL_FLEX: + $maxRemediationLevel = Constants::REMEDIATION_CAPTCHA; + break; + case Constants::BOUNCING_LEVEL_NORMAL: + $maxRemediationLevel = Constants::REMEDIATION_BAN; + break; + default: + throw new BouncerException("Unknown $bouncingLevel"); + } + + + $currentIndex = (int) array_search($remediation, $orderedRemediations); + $maxIndex = (int) array_search( + $maxRemediationLevel, + $orderedRemediations + ); + if ($currentIndex < $maxIndex) { + return $orderedRemediations[$maxIndex]; + } + + return $remediation; + } + + /** + * Configure this instance. + * + * @param array $config An array with all configuration parameters + * + */ + private function configure(array $config): void + { + // Process and validate input configuration. + $configuration = new Configuration(); + $processor = new Processor(); + $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($config)]); + } + + private function getCache(): AbstractCache + { + return $this->getRemediationEngine()->getCacheStorage(); + } + + /** + * @param array $cachedCaptchaVariables + * @param string $ip + * @return bool + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): bool + { + $result = false; + if (\in_array($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'], [null, false])) { + // Early return if no captcha has to be resolved. + $result = true; + } elseif ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) { + // Early return if no form captcha form has been filled. + $result = true; + } elseif (null !== $this->getPostedVariable('refresh') && (int)$this->getPostedVariable('refresh')) { + // Handle image refresh. + // Generate new captcha image for the user + $captchaCouple = Bouncer::buildCaptchaCouple(); + $captchaVariables = [ + 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], + 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], + 'crowdsec_captcha_resolution_failed' => false, + ]; + $duration = $this->getIntegerConfig('captcha_cache_duration'); + $this->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + ); + + $result = true; + } + + return $result; + } +} diff --git a/src/AbstractCache.php b/src/AbstractCache.php deleted file mode 100644 index 37e6134..0000000 --- a/src/AbstractCache.php +++ /dev/null @@ -1,684 +0,0 @@ -logger = $logger; - $this->configs = $configs; - $this->apiClient = new ApiClient($this->configs, $logger); - $this->configureAdapter(); - $this->streamMode = $configs['stream_mode'] ?? false; - $this->cacheExpirationForCleanIp = - $configs['clean_ip_cache_duration'] ?? Constants::CACHE_EXPIRATION_FOR_CLEAN_IP; - $this->cacheExpirationForBadIp = $configs['bad_ip_cache_duration'] ?? Constants::CACHE_EXPIRATION_FOR_BAD_IP; - $this->cacheExpirationForCaptcha = - $configs['captcha_cache_duration'] ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; - $this->cacheExpirationForGeo = $configs['geolocation_cache_duration'] ?? Constants::CACHE_EXPIRATION_FOR_GEO; - $this->fallbackRemediation = $configs['fallback_remediation'] ?? Constants::REMEDIATION_BYPASS; - $this->geolocConfig = $configs['geolocation'] ?? []; - - $cacheConfigItem = $this->adapter->getItem('cacheConfig'); - $cacheConfig = $cacheConfigItem->get(); - $this->warmedUp = (\is_array($cacheConfig) && isset($cacheConfig['warmed_up']) - && true === $cacheConfig['warmed_up']); - $this->logger->debug('', [ - 'type' => 'API_CACHE_INIT', - 'adapter' => \get_class($this->adapter), - 'mode' => ($this->streamMode ? 'stream' : 'live'), - 'fallback_remediation' => $this->fallbackRemediation, - 'exp_clean_ips' => $this->cacheExpirationForCleanIp, - 'exp_bad_ips' => $this->cacheExpirationForBadIp, - 'exp_captcha_flow' => $this->cacheExpirationForCaptcha, - 'exp_geolocation_result' => $this->cacheExpirationForGeo, - 'warmed_up' => ($this->warmedUp ? 'true' : 'false'), - 'geolocation' => $this->geolocConfig, - ]); - } - - /** - * @return AdapterInterface - */ - public function getAdapter(): AdapterInterface - { - return $this->adapter; - } - - /** - * Cache key convention. - * - * @param string $scope - * @param string $value - * @return string - * @throws BouncerException - */ - protected function getCacheKey(string $scope, string $value): string - { - if (!isset($this->cacheKeys[$scope][$value])) { - switch ($scope) { - case Constants::SCOPE_RANGE: - $result = Constants::SCOPE_IP . self::CACHE_SEP . $value; - break; - case Constants::SCOPE_IP: - case Constants::CACHE_TAG_GEO . self::CACHE_SEP . Constants::SCOPE_IP: - case Constants::CACHE_TAG_CAPTCHA . self::CACHE_SEP . Constants::SCOPE_IP: - case Constants::SCOPE_COUNTRY: - $result = $scope . self::CACHE_SEP . $value; - break; - default: - throw new BouncerException('Unknown scope:' . $scope); - } - - /** - * Replace unauthorized symbols. - * - * @see https://symfony.com/doc/current/components/cache/cache_items.html#cache-item-keys-and-values - */ - $this->cacheKeys[$scope][$value] = preg_replace('/[^A-Za-z0-9_.]/', self::CACHE_SEP, $result); - } - - return $this->cacheKeys[$scope][$value]; - } - - /** - * @return ApiClient - */ - public function getClient(): ApiClient - { - return $this->apiClient; - } - - /** - * Retrieved prepared cached variables associated to an Ip - * Set null if not already in cache. - * - * @param string $cacheTag - * @param array $names - * @param string $ip - * - * @return array - * @throws InvalidArgumentException|BouncerException - */ - public function getIpVariables(string $cacheTag, array $names, string $ip): array - { - $cachedVariables = $this->getIpCachedVariables($cacheTag, $ip); - $variables = []; - foreach ($names as $name) { - $variables[$name] = null; - if (isset($cachedVariables[$name])) { - $variables[$name] = $cachedVariables[$name]; - } - } - - return $variables; - } - - /** - * Store variables in cache for some IP and cache tag. - * - * @return void - * - * @throws InvalidArgumentException - * @throws \Psr\Cache\CacheException - * @throws Exception - */ - public function setIpVariables(string $cacheTag, array $pairs, string $ip) - { - $cacheKey = $this->getCacheKey($cacheTag . self::CACHE_SEP . Constants::SCOPE_IP, $ip); - $cachedVariables = $this->getIpCachedVariables($cacheTag, $ip); - foreach ($pairs as $name => $value) { - $cachedVariables[$name] = $value; - } - $this->saveCacheItem($cacheTag, $cacheKey, $cachedVariables); - } - - /** - * Test the connection to the cache system (Redis or Memcached). - * - * @throws BouncerException|InvalidArgumentException if the connection was not successful - * */ - public function testConnection(): void - { - $this->setCustomErrorHandler(); - try { - $this->adapter->getItem(' '); - } finally { - $this->unsetCustomErrorHandler(); - } - } - - /** - * Unset cached variables for some IP and cache tag. - * - * @param string $cacheTag - * @param array $pairs - * @param string $ip - * @return void - * @throws InvalidArgumentException - * @throws \Psr\Cache\CacheException|BouncerException - */ - public function unsetIpVariables(string $cacheTag, array $pairs, string $ip) - { - $cacheKey = $this->getCacheKey($cacheTag . self::CACHE_SEP . Constants::SCOPE_IP, $ip); - $cachedVariables = $this->getIpCachedVariables($cacheTag, $ip); - foreach ($pairs as $name => $value) { - unset($cachedVariables[$name]); - } - $this->saveCacheItem($cacheTag, $cacheKey, $cachedVariables); - } - - /** - * Add remediation to a Symfony Cache Item identified by a cache key. - * - * @throws InvalidArgumentException - * @throws Exception - * @throws \Psr\Cache\CacheException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - protected function addRemediationToCacheItem( - string $cacheKey, - string $type, - int $expiration, - int $decisionId - ): string { - $item = $this->adapter->getItem(base64_encode($cacheKey)); - - // Merge with existing remediations (if any). - $remediations = $item->isHit() ? $item->get() : []; - - $index = array_search(Constants::REMEDIATION_BYPASS, array_column($remediations, 0)); - if (false !== $index) { - $this->logger->debug('', [ - 'type' => 'IP_CLEAN_TO_BAD', - 'cache_key' => $cacheKey, - 'old_remediation' => Constants::REMEDIATION_BYPASS, - ]); - unset($remediations[$index]); - } - - $remediations[] = [ - $type, - $expiration, - $decisionId, - ]; // erase previous decision with the same id - - // Build the item lifetime in cache and sort remediations by priority - $exps = array_column($remediations, 1); - $maxLifetime = $exps ? max($exps) : 0; - $prioritizedRemediations = Remediation::sortRemediationByPriority($remediations); - - $item->set($prioritizedRemediations); - $item->expiresAt(new DateTime('@' . $maxLifetime)); - if ($this->adapter instanceof TagAwareAdapterInterface) { - $item->tag(Constants::CACHE_TAG_REM); - } - - // Save the cache without committing it to the cache system. - // Useful to improve performance when updating the cache. - if (!$this->adapter->saveDeferred($item)) { - throw new BouncerException("cache#$cacheKey: Unable to save this deferred item in cache: " . - "$type for $expiration sec, (decision $decisionId)"); - } - - return $prioritizedRemediations[0][0]; - } - - /** - * Wrap the cacheAdapter to catch warnings. - * - * @throws BouncerException if the connection was not successful - * */ - protected function commit(): bool - { - $this->setCustomErrorHandler(); - try { - $result = $this->adapter->commit(); - } finally { - $this->unsetCustomErrorHandler(); - } - - return $result; - } - - /** - * @throws InvalidArgumentException - */ - protected function defferUpdateCacheConfig(array $config): void - { - $cacheConfigItem = $this->adapter->getItem('cacheConfig'); - $cacheConfig = $cacheConfigItem->isHit() ? $cacheConfigItem->get() : []; - $cacheConfig = array_replace_recursive($cacheConfig, $config); - $cacheConfigItem->set($cacheConfig); - $this->adapter->saveDeferred($cacheConfigItem); - } - - /** - * Format a remediation item of a cache item. - * This format use a minimal amount of data allowing less cache data consumption. - * @throws BouncerException - */ - protected function formatRemediationFromDecision(?array $decision): array - { - if (!$decision) { - $duration = $this->cacheExpirationForCleanIp; - - return [Constants::REMEDIATION_BYPASS, time() + $duration, 0]; - } - - $duration = self::parseDurationToSeconds($decision['duration']); - - // In stream mode, only the stream update has to change the cache state. - if (!$this->streamMode) { - $duration = min($this->cacheExpirationForBadIp, $duration); - } - - return [ - $decision['type'], // ex: ban, captcha - time() + $duration, // expiration timestamp - $decision['id'], - ]; - } - - /** - * @return array - */ - protected function getScopes(): array - { - if (null === $this->scopes) { - $finalScopes = [Constants::SCOPE_IP, Constants::SCOPE_RANGE]; - if (!empty($this->geolocConfig['enabled'])) { - $finalScopes[] = Constants::SCOPE_COUNTRY; - } - $this->scopes = $finalScopes; - } - - return $this->scopes; - } - - /** - * Used in both mode (stream and rupture). - * This method formats the cached item as a remediation. - * It returns the highest remediation level found. - * - * @throws InvalidArgumentException - */ - protected function hit(string $ip): string - { - $remediations = $this->adapter->getItem(base64_encode($ip))->get(); - - // We apply array values first because keys are ids. - /** @var array $firstRemediation */ - $firstRemediation = array_values($remediations)[0]; - - return $firstRemediation[0]; - } - - /** - * This method is called when nothing has been found in cache for the requested value/cacheScope pair (IP,country). - * In live mode is enabled, calls the API for decisions concerning the specified IP - * In stream mode, as we consider cache is the single source of truth, the value is considered clean. - * Finally, the result is stored in caches for further calls. - * - * @throws InvalidArgumentException - * @throws Exception|\Psr\Cache\CacheException - */ - protected function miss(string $value, string $cacheScope): string - { - $decisions = []; - $cacheKey = $this->getCacheKey($cacheScope, $value); - if (!$this->streamMode) { - if (Constants::SCOPE_IP === $cacheScope) { - $this->logger->debug('', ['type' => 'DIRECT_API_CALL', 'ip' => $value]); - $decisions = $this->apiClient->getFilteredDecisions(['ip' => $value]); - } elseif (Constants::SCOPE_COUNTRY === $cacheScope) { - $this->logger->debug('', ['type' => 'DIRECT_API_CALL', 'country' => $value]); - $decisions = $this->apiClient->getFilteredDecisions([ - 'scope' => Constants::SCOPE_COUNTRY, - 'value' => $value, - ]); - } - } - // In stream mode, we do not save bypass decision in cache - if ($this->streamMode && !$decisions) { - return Constants::REMEDIATION_BYPASS; - } - - return $this->saveRemediationsForCacheKey($decisions, $cacheKey); - } - - /** - * Remove a decision from a Symfony Cache Item identified by a cache key. - * - * @throws InvalidArgumentException - * @throws Exception - * @throws \Psr\Cache\CacheException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - protected function removeDecisionFromRemediationItem(string $cacheKey, int $decisionId): bool - { - $item = $this->adapter->getItem(base64_encode($cacheKey)); - $remediations = $item->get(); - - $index = false; - if ($remediations) { - $index = array_search($decisionId, array_column($remediations, 2)); - } - - // If decision was not found for this cache item early return. - if (false === $index) { - return false; - } - unset($remediations[$index]); - - if (!$remediations) { - $this->logger->debug('', [ - 'type' => 'CACHE_ITEM_REMOVED', - 'cache_key' => $cacheKey, - ]); - $this->adapter->deleteItem(base64_encode($cacheKey)); - - return true; - } - // Build the item lifetime in cache and sort remediations by priority - $exps = array_column($remediations, 1); - $maxLifetime = $exps ? max($exps) : 0; - $cacheContent = Remediation::sortRemediationByPriority($remediations); - $item->expiresAt(new DateTime('@' . $maxLifetime)); - $item->set($cacheContent); - if ($this->adapter instanceof TagAwareAdapterInterface) { - $item->tag(Constants::CACHE_TAG_REM); - } - - // Save the cache without committing it to the cache system. - // Useful to improve performance when updating the cache. - if (!$this->adapter->saveDeferred($item)) { - throw new BouncerException("cache#$cacheKey: Unable to save item"); - } - $this->logger->debug('', [ - 'type' => 'DECISION_REMOVED', - 'decision' => $decisionId, - 'cache_key' => $cacheKey, - ]); - - return true; - } - - /** - * @param string $cacheTag - * @param string $cacheKey - * @param mixed $cachedVariables - * @return void - * @throws InvalidArgumentException - * @throws \Psr\Cache\CacheException - */ - protected function saveCacheItem(string $cacheTag, string $cacheKey, $cachedVariables): void - { - $duration = (Constants::CACHE_TAG_CAPTCHA === $cacheTag) - ? $this->cacheExpirationForCaptcha : $this->cacheExpirationForGeo; - $item = $this->adapter->getItem(base64_encode($cacheKey)); - $item->set($cachedVariables); - $item->expiresAt(new DateTime("+$duration seconds")); - if ($this->adapter instanceof TagAwareAdapterInterface) { - $item->tag($cacheTag); - } - $this->adapter->save($item); - } - - /** - * When Memcached connection fail, it throws an unhandled warning. - * To catch this warning as a clean exception we have to temporarily change the error handler. - * @throws BouncerException - */ - protected function setCustomErrorHandler(): void - { - if ($this->adapter instanceof MemcachedAdapter) { - set_error_handler(function ($errno, $errstr) { - $message = "Error when connecting to Memcached. (Error level: $errno)" . - "Please fix the Memcached DSN or select another cache technology." . - "Original message was: $errstr"; - throw new BouncerException($message); - }); - } - } - - /** - * When the selected cache adapter is MemcachedAdapter, revert to the previous error handler. - * */ - protected function unsetCustomErrorHandler(): void - { - if ($this->adapter instanceof MemcachedAdapter) { - restore_error_handler(); - } - } - - /** - * @return void - * @throws BouncerException - * @throws CacheException - * @throws ErrorException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - private function configureAdapter(): void - { - $cacheSystem = $this->configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; - switch ($cacheSystem) { - case Constants::CACHE_SYSTEM_PHPFS: - $this->adapter = new TagAwareAdapter( - new PhpFilesAdapter('', 0, $this->configs['fs_cache_path']) - ); - break; - - case Constants::CACHE_SYSTEM_MEMCACHED: - $memcachedDsn = $this->configs['memcached_dsn']; - if (empty($memcachedDsn)) { - throw new BouncerException('The selected cache technology is Memcached.' . - ' Please set a Memcached DSN or select another cache technology.'); - } - /** - * Using a MemcachedAdapter with a TagAwareAdapter for storing tags is discouraged. - * @see \Symfony\Component\Cache\Adapter\MemcachedAdapter::__construct comment - */ - $this->adapter = new MemcachedAdapter(MemcachedAdapter::createConnection($memcachedDsn)); - break; - - case Constants::CACHE_SYSTEM_REDIS: - $redisDsn = $this->configs['redis_dsn']; - if (empty($redisDsn)) { - throw new BouncerException('The selected cache technology is Redis.' . - ' Please set a Redis DSN or select another cache technology.'); - } - - try { - $this->adapter = new RedisTagAwareAdapter((RedisAdapter::createConnection($redisDsn))); - } catch (Exception $e) { - throw new BouncerException('Error when connecting to Redis.' . - ' Please fix the Redis DSN or select another cache technology.' . - ' Initial error was: ' . $e->getMessage()); - } - break; - - default: - throw new BouncerException("Unknown selected cache technology: $cacheSystem"); - } - } - - /** - * Retrieve raw cache item for some IP and cache tag. - * - * @return array|mixed - * - * @throws InvalidArgumentException|BouncerException - */ - private function getIpCachedVariables(string $cacheTag, string $ip) - { - $cacheKey = $this->getCacheKey($cacheTag . self::CACHE_SEP . Constants::SCOPE_IP, $ip); - $cachedVariables = []; - if ($this->adapter->hasItem(base64_encode($cacheKey))) { - $cachedVariables = $this->adapter->getItem(base64_encode($cacheKey))->get(); - } - - return $cachedVariables; - } - - /** - * Parse "duration" entries returned from API to a number of seconds. - * @throws BouncerException - */ - private static function parseDurationToSeconds(string $duration): int - { - $re = '/(-?)(?:(?:(\d+)h)?(\d+)m)?(\d+).\d+(m?)s/m'; - preg_match($re, $duration, $matches); - if (!\count($matches)) { - throw new BouncerException('Unable to parse the following duration: ' . $duration); - } - $seconds = 0; - if (isset($matches[2])) { - $seconds += ((int)$matches[2]) * 3600; // hours - } - if (isset($matches[3])) { - $seconds += ((int)$matches[3]) * 60; // minutes - } - $secondsPart = 0; - if (isset($matches[4])) { - $secondsPart += ((int)$matches[4]); // seconds - } - if ('m' === ($matches[5])) { // units in milliseconds - $secondsPart *= 0.001; - } - $seconds += $secondsPart; - if ('-' === ($matches[1])) { // negative - $seconds *= -1; - } - - return (int)round($seconds); - } - - /** - * Update the cached remediation of the specified cacheKey from these new decisions. - * - * @throws InvalidArgumentException|\Psr\Cache\CacheException - * @throws BouncerException - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - private function saveRemediationsForCacheKey(array $decisions, string $cacheKey): string - { - $remediationResult = Constants::REMEDIATION_BYPASS; - if (\count($decisions)) { - foreach ($decisions as $decision) { - if (!\in_array($decision['type'], Constants::ORDERED_REMEDIATIONS)) { - $this->logger->warning('', [ - 'type' => 'UNKNOWN_REMEDIATION', - 'unknown' => $decision['type'], - 'fallback' => $this->fallbackRemediation]); - $decision['type'] = $this->fallbackRemediation; - } - $remediation = $this->formatRemediationFromDecision($decision); - $type = $remediation[0]; - $exp = $remediation[1]; - $id = $remediation[2]; - $remediationResult = $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - } - } else { - $remediation = $this->formatRemediationFromDecision(null); - $type = $remediation[0]; - $exp = $remediation[1]; - $id = $remediation[2]; - $remediationResult = $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - } - $this->commit(); - - return $remediationResult; - } -} diff --git a/src/ApiCache.php b/src/ApiCache.php deleted file mode 100644 index 17b9f34..0000000 --- a/src/ApiCache.php +++ /dev/null @@ -1,636 +0,0 @@ -setCustomErrorHandler(); - try { - $cleared = $this->adapter->clear(); - } finally { - $this->unsetCustomErrorHandler(); - } - $this->warmedUp = false; - $this->defferUpdateCacheConfig(['warmed_up' => $this->warmedUp]); - $this->commit(); - $this->logger->info('', ['type' => 'CACHE_CLEARED']); - - return $cleared; - } - - /** - * Request the cache for the specified IP. - * - * @return string the computed remediation string, or null if no decision was found - * - * @throws InvalidArgumentException - * @throws Exception|CacheException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - public function get(AddressInterface $address): string - { - $ip = $address->toString(); - $this->logger->debug('', ['type' => 'START_IP_CHECK', 'ip' => $ip]); - - if ($this->streamMode && !$this->warmedUp) { - $message = 'CrowdSec Bouncer configured in "stream" mode. ' . - 'Please warm the cache up before trying to access it.'; - throw new BouncerException($message); - } - - // Handle Ip and Range remediation - $remediations = [[$this->handleCacheRemediation(Constants::SCOPE_IP, $ip), '', '']]; - - // Handle Geolocation remediation - if (!empty($this->geolocConfig['enabled'])) { - $geolocation = new Geolocation(); - $countryToQuery = $geolocation->getCountryToQuery($this->geolocConfig, $ip, $this, $this->logger); - if ($countryToQuery) { - $remediations[] = [$this->handleCacheRemediation(Constants::SCOPE_COUNTRY, $countryToQuery), '', '']; - } - } - $prioritizedRemediations = Remediation::sortRemediationByPriority($remediations); - $finalRemediation = $prioritizedRemediations[0][0]; - $this->logger->info('', ['type' => 'FINAL_REMEDIATION', 'ip' => $ip, 'remediation' => $finalRemediation]); - - return $finalRemediation; - } - - /** - * Prune the cache (only when using PHP File System cache). - * @throws BouncerException - */ - public function prune(): bool - { - if ($this->adapter instanceof PruneableInterface) { - $pruned = $this->adapter->prune(); - $this->logger->debug('', ['type' => 'CACHE_PRUNED']); - - return $pruned; - } - - throw new BouncerException('Cache Adapter' . \get_class($this->adapter) . ' is not prunable.'); - } - - /** - * Used in stream mode only. - * Pull decisions updates from the API and update the cached remediations. - * Used for the stream mode when we have to update the remediations list. - * - * @return array number of deleted and new decisions, and errors when processing decisions - * - * @throws InvalidArgumentException|CacheException|BouncerException - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - public function pullUpdates(): array - { - $deletionErrors = []; - $addErrors = []; - if (!$this->warmedUp) { - $warmUpResult = $this->warmUp(); - $addErrors = $warmUpResult['errors']; - - return [ - 'deleted' => 0, - 'new' => $warmUpResult['count'], - 'deletionErrors' => $deletionErrors, - 'addErrors' => $addErrors - ]; - } - - $this->logger->debug('', ['type' => 'START_CACHE_UPDATE']); - $decisionsDiff = $this->apiClient->getStreamedDecisions(false, $this->getScopes()); - $newDecisions = $decisionsDiff['new']; - $deletedDecisions = $decisionsDiff['deleted']; - - $nbDeleted = 0; - if ($deletedDecisions) { - $removingResult = $this->removeRemediations($deletedDecisions); - $deletionErrors = $removingResult['errors']; - $nbDeleted = $removingResult['count']; - } - - $nbNew = 0; - if ($newDecisions) { - $saveResult = $this->saveRemediations($newDecisions); - if (!empty($saveResult['success'])) { - $addErrors = $saveResult['errors'] ?? 0; - $nbNew = $saveResult['count'] ?? 0; - } else { - $this->logger->warning('', [ - 'type' => 'CACHE_UPDATED_FAILED', - 'message' => 'Something went wrong while committing to cache adapter']); - } - } - - $this->logger->debug('', ['type' => 'CACHE_UPDATED', 'deleted' => $nbDeleted, 'new' => $nbNew]); - - return [ - 'deleted' => $nbDeleted, - 'new' => $nbNew, - 'deletionErrors' => $deletionErrors, - 'addErrors' => $addErrors - ]; - } - - /** - * Used in stream mode only. - * Warm the cache up. - * Used when the stream mode has just been activated. - * - * @return array "count": number of decisions added, "errors": decisions not added - * - * @throws InvalidArgumentException|CacheException|BouncerException - */ - public function warmUp(): array - { - $addErrors = []; - - if ($this->warmedUp) { - $this->clear(); - } - $this->logger->debug('', ['type' => 'START_CACHE_WARMUP']); - $decisionsDiff = $this->apiClient->getStreamedDecisions(true, $this->getScopes()); - $newDecisions = $decisionsDiff['new']; - - $nbNew = 0; - if ($newDecisions) { - $saveResult = $this->saveRemediations($newDecisions); - $addErrors = $saveResult['errors']; - $this->warmedUp = $saveResult['success']; - $this->defferUpdateCacheConfig(['warmed_up' => $this->warmedUp]); - $this->commit(); - if (!$this->warmedUp) { - throw new BouncerException('Unable to warm the cache up'); - } - $nbNew = \count($newDecisions); - } - - // Store the fact that the cache has been warmed up. - $this->defferUpdateCacheConfig(['warmed_up' => true]); - - $this->commit(); - $this->logger->info('', ['type' => 'CACHE_WARMED_UP', 'added_decisions' => $nbNew]); - - return ['count' => $nbNew, 'errors' => $addErrors]; - } - - /** - * @param string $cacheScope - * @param string $value - * - * @return string - * @throws InvalidArgumentException - * @throws CacheException|BouncerException - */ - private function handleCacheRemediation(string $cacheScope, string $value): string - { - $cacheKey = $this->getCacheKey($cacheScope, $value); - $hasItem = $this->adapter->hasItem(base64_encode($cacheKey)); - $remediation = $hasItem ? $this->hit($cacheKey) : $this->miss($value, $cacheScope); - $cache = $hasItem ? 'hit' : 'miss'; - $this->logRemediation($cacheScope, $value, $cache, $remediation); - - return $remediation; - } - - /** - * @param string $cacheKey - * @param array $decision - * @param int $count - * @return void - * @throws InvalidArgumentException - * @throws CacheException - * - */ - private function handleDecisionRemoving(string $cacheKey, array $decision, int &$count): void - { - if (!$this->removeDecisionFromRemediationItem($cacheKey, $decision['id'])) { - $this->logger->debug('', [ - 'type' => 'DECISION_TO_REMOVE_NOT_FOUND_IN_CACHE', - 'decision' => $decision['id'] - ]); - - return; - } - $this->logger->debug('', [ - 'type' => 'DECISION_REMOVED', - 'decision' => $decision['id'], - 'value' => $decision['value'], - ]); - ++$count; - } - - /** - * @param array $decision - * @param bool $success - * @param int $count - * @return void - */ - private function handleSuccess(array $decision, bool $success, int &$count): void - { - if ($success) { - $this->logger->debug('', [ - 'type' => 'DECISION_REMOVED', - 'decision' => $decision['id'], - 'scope' => $decision['scope'], - 'value' => $decision['value'], - ]); - ++$count; - return; - } - // The API may return stale deletion events due to API design. - // Ignoring them is therefore not a problem. - $this->logger->debug('', [ - 'type' => 'DECISION_TO_REMOVE_NOT_FOUND_IN_CACHE', - 'decision' => $decision['id'] - ]); - } - - /** - * @param string $cacheScope - * @param string $value - * @param string $cache - * @param string $remediation - * @return void - */ - private function logRemediation(string $cacheScope, string $value, string $cache, string $remediation): void - { - if (Constants::REMEDIATION_BYPASS === $remediation) { - $this->logger->info('', [ - 'type' => 'CLEAN_VALUE', - 'scope' => $cacheScope, - 'value' => $value, - 'cache' => $cache - ]); - - return; - } - - $this->logger->warning('', [ - 'type' => 'BAD_VALUE', - 'value' => $value, - 'scope' => $cacheScope, - 'remediation' => $remediation, - 'cache' => $cache, - ]); - } - - /** - * @param array $decision - * @param int $count - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - private function removeCountryScopedDecision(array $decision, int &$count): void - { - $cacheKey = $this->getCacheKey(Constants::SCOPE_COUNTRY, $decision['value']); - $this->handleDecisionRemoving($cacheKey, $decision, $count); - } - - /** - * @param array $decision - * @param int $count - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - * - * @SuppressWarnings(PHPMD.ElseExpression) - */ - private function removeIpScopedDecision(array $decision, int &$count): void - { - $address = Factory::parseAddressString($decision['value'], 3); - if (null === $address) { - $this->logger->warning('', [ - 'type' => 'INVALID_IP_TO_REMOVE_FROM_REMEDIATION', - 'decision' => $decision, - ]); - return; - } - $cacheKey = $this->getCacheKey(Constants::SCOPE_IP, $address->toString()); - $this->handleDecisionRemoving($cacheKey, $decision, $count); - } - - /** - * @param array $decision - * @param int $count - * @param array $errors - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - private function removeRangeScopedDecision(array $decision, int &$count, array &$errors): void - { - $range = Subnet::parseString($decision['value']); - if (null === $range) { - $this->logger->warning('', [ - 'type' => 'INVALID_RANGE_TO_REMOVE_FROM_REMEDIATION', - 'decision' => $decision, - ]); - return; - } - $addressType = $range->getAddressType(); - $isIpv6 = (Type::T_IPv6 === $addressType); - if ($isIpv6 || ($range->getNetworkPrefix() < 24)) { - $error = - [ - 'type' => 'DECISION_RANGE_TO_REMOVE_IS_TOO_LARGE', - 'decision' => $decision['id'], - 'range' => $decision['value'] - ]; - $errors[] = $error; - $this->logger->warning('', $error); - return; - } - - $comparableEndAddress = $range->getComparableEndString(); - $address = $range->getStartAddress(); - $cacheKey = $this->getCacheKey($decision['scope'], $address->toString()); - if (!$this->removeDecisionFromRemediationItem($cacheKey, $decision['id'])) { - $this->logger->debug('', [ - 'type' => 'DECISION_TO_REMOVE_NOT_FOUND_IN_CACHE', - 'decision' => $decision['id'] - ]); - } - $ipCount = 1; - $success = true; - do { - $address = $address->getNextAddress(); - if (null === $address) { - $this->logger->warning('', [ - 'type' => 'INVALID_NEXT_ADDRESS_TO_REMOVE_FROM_REMEDIATION', - 'decision' => $decision, - ]); - break; - } - $cacheKey = $this->getCacheKey(Constants::SCOPE_RANGE, $address->toString()); - if (!$this->removeDecisionFromRemediationItem($cacheKey, $decision['id'])) { - $success = false; - } - ++$ipCount; - if ($ipCount >= 1000) { - $message = 'Unable to store the decision ' . $decision['id'] . - ', the IP range: ' . $decision['value'] . - ' is too large and can cause storage problem. Decision ignored.'; - throw new BouncerException($message); - } - } while (0 !== strcmp($address->getComparableString(), $comparableEndAddress)); - - $this->handleSuccess($decision, $success, $count); - } - - /** - * @throws InvalidArgumentException - * @throws Exception|CacheException - */ - private function removeRemediations(array $decisions): array - { - $errors = []; - $count = 0; - foreach ($decisions as $decision) { - $this->removeSingleDecision($decision, $count, $errors); - } - - $this->commit(); - - return ['count' => $count, 'errors' => $errors]; - } - - /** - * @param array $decision - * @param int $count - * @param array $errors - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - */ - private function removeSingleDecision(array $decision, int &$count, array &$errors) - { - switch ($decision['scope']) { - case Constants::SCOPE_IP: - $this->removeIpScopedDecision($decision, $count); - break; - - case Constants::SCOPE_RANGE: - $this->removeRangeScopedDecision($decision, $count, $errors); - break; - - case Constants::SCOPE_COUNTRY: - $this->removeCountryScopedDecision($decision, $count); - break; - default: - break; - } - } - - /** - * @param array $decision - * @param int $count - * @param string $type - * @param int $exp - * @param int $id - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - */ - private function saveCountryScopedDecision(array $decision, int &$count, string $type, int $exp, int $id): void - { - $cacheKey = $this->getCacheKey(Constants::SCOPE_COUNTRY, $decision['value']); - $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - ++$count; - } - - /** - * @param array $decision - * @param int $count - * @param string $type - * @param int $exp - * @param int $id - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - */ - private function saveIpScopedDecision(array $decision, int &$count, string $type, int $exp, int $id): void - { - $address = Factory::parseAddressString($decision['value'], 3); - if (null === $address) { - $this->logger->warning('', [ - 'type' => 'INVALID_IP_TO_ADD_FROM_REMEDIATION', - 'decision' => $decision, - ]); - return; - } - $cacheKey = $this->getCacheKey(Constants::SCOPE_IP, $address->toString()); - $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - ++$count; - } - - /** - * @param array $decision - * @param int $count - * @param array $errors - * @param string $type - * @param int $exp - * @param int $id - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - private function saveRangeScopedDecision( - array $decision, - int &$count, - array &$errors, - string $type, - int $exp, - int $id - ): void { - $range = Subnet::parseString($decision['value']); - if (null === $range) { - $this->logger->warning('', [ - 'type' => 'INVALID_RANGE_TO_ADD_FROM_REMEDIATION', - 'decision' => $decision, - ]); - return; - } - $addressType = $range->getAddressType(); - $isIpv6 = (Type::T_IPv6 === $addressType); - if ($isIpv6 || ($range->getNetworkPrefix() < 24)) { - $error = - [ - 'type' => 'DECISION_RANGE_TO_ADD_IS_TOO_LARGE', - 'decision' => $decision['id'], - 'range' => $decision['value'], - 'remediation' => $type, - 'expiration' => $exp - ]; - $errors[] = $error; - $this->logger->warning('', $error); - return; - } - $comparableEndAddress = $range->getComparableEndString(); - $address = $range->getStartAddress(); - $cacheKey = $this->getCacheKey($decision['scope'], $address->toString()); - $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - $ipCount = 1; - do { - $address = $address->getNextAddress(); - if (null === $address) { - $this->logger->warning('', [ - 'type' => 'INVALID_NEXT_ADDRESS_TO_REMOVE_FROM_REMEDIATION', - 'decision' => $decision, - ]); - break; - } - $cacheKey = $this->getCacheKey(Constants::SCOPE_RANGE, $address->toString()); - $this->addRemediationToCacheItem($cacheKey, $type, $exp, $id); - ++$ipCount; - if ($ipCount >= 1000) { - $message = 'Unable to store the decision ' . $decision['id'] . - ', the IP range: ' . $decision['value'] . - ' is too large and can cause storage problem. Decision ignored.'; - throw new BouncerException($message); - } - } while (0 !== strcmp($address->getComparableString(), $comparableEndAddress)); - ++$count; - } - - /** - * Update the cached remediations from these new decisions. - * - * @throws InvalidArgumentException - * @throws Exception|CacheException - * - */ - private function saveRemediations(array $decisions): array - { - $errors = []; - $count = 0; - foreach ($decisions as $decision) { - $this->saveSingleDecision($decision, $count, $errors); - } - - return ['success' => $this->commit(), 'errors' => $errors, 'count' => $count]; - } - - /** - * @param array $decision - * @param int $count - * @param array $errors - * @return void - * @throws BouncerException - * @throws InvalidArgumentException - * @throws CacheException - */ - private function saveSingleDecision(array $decision, int &$count, array &$errors) - { - $remediation = $this->formatRemediationFromDecision($decision); - if (!\in_array($remediation[0], Constants::ORDERED_REMEDIATIONS)) { - $this->logger->warning('', [ - 'type' => 'UNKNOWN_REMEDIATION', - 'unknown' => $remediation[0], - 'fallback' => $this->fallbackRemediation]); - $remediation[0] = $this->fallbackRemediation; - } - $type = (string) $remediation[0]; - $exp = (int) $remediation[1]; - $id = (int) $remediation[2]; - switch ($decision['scope']) { - case Constants::SCOPE_IP: - $this->saveIpScopedDecision($decision, $count, $type, $exp, $id); - break; - - case Constants::SCOPE_RANGE: - $this->saveRangeScopedDecision($decision, $count, $errors, $type, $exp, $id); - break; - - case Constants::SCOPE_COUNTRY: - $this->saveCountryScopedDecision($decision, $count, $type, $exp, $id); - break; - - default: - break; - } - } -} diff --git a/src/ApiClient.php b/src/ApiClient.php deleted file mode 100644 index 0ccb2fa..0000000 --- a/src/ApiClient.php +++ /dev/null @@ -1,97 +0,0 @@ -logger = $logger; - $this->configs = $configs; - $useCurl = !empty($this->configs['use_curl']); - if (empty($this->configs['api_user_agent'])) { - throw new BouncerException('User agent is required'); - } - $userAgent = $this->configs['api_user_agent']; - $this->configs['headers'] = [ - 'User-Agent' => $this->configs['api_user_agent'], - 'Accept' => 'application/json', - ]; - if (!empty($this->configs['api_key'])) { - $this->configs['headers']['X-Api-Key'] = $this->configs['api_key']; - } - - $this->restClient = $useCurl ? - new Curl($this->configs, $this->logger) : - new FileGetContents($this->configs, $this->logger); - - $this->logger->debug('', [ - 'type' => 'API_CLIENT_INIT', - 'user_agent' => $userAgent, - 'rest_client' => \get_class($this->restClient) - ]); - } - - /** - * Request decisions using the specified $filter array. - * @throws BouncerException - */ - public function getFilteredDecisions(array $filter): array - { - return $this->restClient->request('/v1/decisions', $filter) ?: []; - } - - public function getRestClient(): AbstractClient - { - return $this->restClient; - } - - /** - * Request decisions using the stream mode. When the $startup flag is used, all the decisions are returned. - * Else only the decisions updates (add or remove) from the last stream call are returned. - * @throws BouncerException - * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) - */ - public function getStreamedDecisions( - bool $startup = false, - array $scopes = [Constants::SCOPE_IP, Constants::SCOPE_RANGE] - ): array { - /** @var array */ - return $this->restClient->request( - '/v1/decisions/stream', - ['startup' => $startup ? 'true' : 'false', 'scopes' => implode(',', $scopes)] - ); - } -} diff --git a/src/Bouncer.php b/src/Bouncer.php deleted file mode 100644 index 14a1e87..0000000 --- a/src/Bouncer.php +++ /dev/null @@ -1,332 +0,0 @@ -pushHandler(new NullHandler()); - } - $this->logger = $logger; - $this->configure($configs); - /** @var int $index */ - $index = array_search( - $this->configs['max_remediation_level'], - Constants::ORDERED_REMEDIATIONS - ); - $this->maxRemediationLevelIndex = $index; - - $this->apiCache = new ApiCache( - $this->configs, - $logger - ); - - $this->logger->debug('', [ - 'type' => 'BOUNCER_INIT', - 'logger' => \get_class($this->logger), - 'max_remediation_level' => $this->maxRemediationLevelIndex, - 'configs' => array_merge($this->configs, ['api_key' => '***']) - ]); - } - - /** - * Build a captcha couple. - * - * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" - * representing the image data - */ - public static function buildCaptchaCouple(): array - { - $captchaBuilder = new CaptchaBuilder(); - - return [ - 'phrase' => $captchaBuilder->getPhrase(), - 'inlineImage' => $captchaBuilder->build()->inline(), - ]; - } - - /** - * Check if the captcha filled by the user is correct or not. - * We are permissive with the user (0 is interpreted as "o" and 1 in interpreted as "l"). - * - * @param string $expected The expected phrase - * @param string $try The phrase to check (the user input) - * @param string $ip The IP of the use (for logging purpose) - * - * @return bool If the captcha input was correct or not - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - public function checkCaptcha(string $expected, string $try, string $ip): bool - { - $solved = PhraseBuilder::comparePhrases($expected, $try); - $this->logger->warning('', [ - 'type' => 'CAPTCHA_SOLVED', - 'ip' => $ip, - 'resolution' => $solved, - ]); - - return $solved; - } - - /** - * This method clear the full data in cache. - * - * @return bool If the cache has been successfully cleared or not - * - * @throws InvalidArgumentException|BouncerException - */ - public function clearCache(): bool - { - return $this->apiCache->clear(); - } - - /** - * Returns a default "CrowdSec 403" HTML template. - * The input $config should match the TemplateConfiguration input format. - * - * @param array $config An array of template configuration parameters - * - * @return string The HTML compiled template - */ - public static function getAccessForbiddenHtmlTemplate(array $config): string - { - // Process template configuration. - $configuration = new TemplateConfiguration(); - $processor = new Processor(); - $config = $processor->processConfiguration($configuration, [$config]); - $template = new Template('ban.html.twig'); - - return $template->render($config); - } - - public function getApiCache(): ApiCache - { - return $this->apiCache; - } - - public function getCacheAdapter(): AdapterInterface - { - return $this->getApiCache()->getAdapter(); - } - - /** - * Returns a default "CrowdSec Captcha (401)" HTML template. - * The input $config should match the TemplateConfiguration input format. - * - * @param bool $error - * @param string $captchaImageSrc - * @param string $captchaResolutionFormUrl - * @param array $config - * @return string - */ - public static function getCaptchaHtmlTemplate( - bool $error, - string $captchaImageSrc, - string $captchaResolutionFormUrl, - array $config - ): string { - // Process template configuration. - $configuration = new TemplateConfiguration(); - $processor = new Processor(); - $config = $processor->processConfiguration($configuration, [$config]); - $template = new Template('captcha.html.twig'); - - return $template->render(array_merge( - $config, - [ - 'error' => $error, - 'captcha_img' => $captchaImageSrc, - 'captcha_resolution_url' => $captchaResolutionFormUrl - ] - )); - } - - public function getClient(): ApiClient - { - return $this->getApiCache()->getClient(); - } - - /** - * Retrieve Bouncer configuration by name - * - * @param string $name - * @return mixed - */ - public function getConfig(string $name) - { - return $this->configs[$name]; - } - - /** - * Retrieve Bouncer configurations - * - * @return array - */ - public function getConfigs(): array - { - return $this->configs; - } - - /** - * Returns the logger instance. - * - * @return LoggerInterface the logger used by this library - */ - public function getLogger(): LoggerInterface - { - return $this->logger; - } - - /** - * Get the remediation for the specified IP. This method use the cache layer. - * In live mode, when no remediation was found in cache, - * the cache system will call the API to check if there is a decision. - * - * @param string $ip The IP to check - * - * @return string the remediation to apply (ex: 'ban', 'captcha', 'bypass') - * - * @throws InvalidArgumentException|\Psr\Cache\CacheException|BouncerException - */ - public function getRemediationForIp(string $ip): string - { - $address = Factory::parseAddressString($ip, ParseStringFlag::MAY_INCLUDE_ZONEID); - if (null === $address) { - throw new BouncerException("IP $ip format is invalid."); - } - $remediation = $this->apiCache->get($address); - - return $this->capRemediationLevel($remediation); - } - - public function getRestClient(): AbstractClient - { - return $this->getClient()->getRestClient(); - } - - /** - * This method prune the cache: it removes all the expired cache items. - * - * @return bool If the cache has been successfully pruned or not - * @throws BouncerException - */ - public function pruneCache(): bool - { - return $this->apiCache->prune(); - } - - /** - * Used in stream mode only. - * This method should be called periodically (ex: crontab) in an asynchronous way to update the bouncer cache. - * - * @return array Number of deleted and new decisions, and errors when processing decisions - * - * @throws InvalidArgumentException|\Psr\Cache\CacheException|BouncerException - */ - public function refreshBlocklistCache(): array - { - return $this->apiCache->pullUpdates(); - } - - /** - * Test the connection to the cache system (Redis or Memcached). - * - * @return void If the connection was successful or not - * - * @throws BouncerException|InvalidArgumentException if the connection was not successful - * */ - public function testConnection() - { - $this->apiCache->testConnection(); - } - - /** - * Used in stream mode only. - * This method should be called only to force a cache warm up. - * - * @return array "count": number of decisions added, "errors": decisions not added - * - * @throws InvalidArgumentException|\Psr\Cache\CacheException|BouncerException - */ - public function warmBlocklistCacheUp(): array - { - return $this->apiCache->warmUp(); - } - - /** - * Cap the remediation to a fixed value given in configuration. - * - * @param string $remediation The maximum remediation that can ban applied (ex: 'ban', 'captcha', 'bypass') - * - * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') - */ - private function capRemediationLevel(string $remediation): string - { - $currentIndex = array_search($remediation, Constants::ORDERED_REMEDIATIONS); - if ($currentIndex < $this->maxRemediationLevelIndex) { - return Constants::ORDERED_REMEDIATIONS[$this->maxRemediationLevelIndex]; - } - - return $remediation; - } - - /** - * Configure this instance. - * - * @param array $config An array with all configuration parameters - * - */ - private function configure(array $config): void - { - // Process and validate input configuration. - $configuration = new Configuration(); - $processor = new Processor(); - $this->configs = $processor->processConfiguration($configuration, [$config]); - } -} diff --git a/src/IBounce.php b/src/BouncerInterface.php similarity index 57% rename from src/IBounce.php rename to src/BouncerInterface.php index 919dab7..0280e67 100644 --- a/src/IBounce.php +++ b/src/BouncerInterface.php @@ -14,23 +14,8 @@ * @copyright Copyright (c) 2021+ CrowdSec * @license MIT License */ -interface IBounce +interface BouncerInterface { - /** - * @return array array of option required to build the ban wall template - */ - public function getBanWallOptions(): array; - - /** - * Get the bouncer instance. - */ - public function getBouncerInstance(array $settings): Bouncer; - - /** - * @return array array of option required to build the captcha wall template - */ - public function getCaptchaWallOptions(): array; - /** * @return string The current IP, even if it's the IP of a proxy */ @@ -51,25 +36,10 @@ public function getPostedVariable(string $name): ?string; */ public function getRemoteIp(): string; - /** - * @return array [[string, string], ...] Returns IP ranges to trust as proxies as an array of comparable ip bounds - */ - public function getTrustForwardedIpBoundsList(): array; - - /** - * Init the bouncer. - */ - public function init(array $configs): Bouncer; - - /** - * Init the logger. - */ - public function initLogger(array $configs): void; - /** * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. */ - public function safelyBounce(array $configs): bool; + public function safelyBounce(): bool; /** * Send HTTP response. diff --git a/src/Configuration.php b/src/Configuration.php index a35df1a..5b4e9d7 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -23,62 +23,138 @@ */ class Configuration implements ConfigurationInterface { + + /** + * @var string[] + */ + protected $keys = [ + 'use_curl', + 'forced_test_ip', + 'forced_test_forwarded_ip', + 'debug_mode', + 'disable_prod_log', + 'log_directory_path', + 'display_errors', + 'cache_system', + 'captcha_cache_duration', + 'excluded_uris', + 'trust_ip_forward_array', + 'bouncing_level', + 'hide_mentions', + 'custom_css', + 'color', + 'text', + ]; + + /** + * Keep only necessary configs + * @param array $configs + * @return array + */ + public function cleanConfigs(array $configs): array + { + return array_intersect_key($configs, array_flip($this->keys)); + } + /** * {@inheritdoc} - * @throws InvalidArgumentException|RuntimeException */ public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('config'); /** @var ArrayNodeDefinition $rootNode */ $rootNode = $treeBuilder->getRootNode(); - $this->validate($rootNode); $this->addConnectionNodes($rootNode); $this->addDebugNodes($rootNode); $this->addBouncerNodes($rootNode); $this->addCacheNodes($rootNode); - $this->addGeolocationNodes($rootNode); + $this->addTemplateNodes($rootNode); return $treeBuilder; } + private function addTemplateNodes($rootNode) + { + // @TODO update docs for config + $defaultSubtitle = 'This page is protected against cyber attacks and your IP has been banned by our system.'; + $rootNode->children() + ->arrayNode('color')->addDefaultsIfNotSet() + ->children() + ->arrayNode('text')->addDefaultsIfNotSet() + ->children() + ->scalarNode('primary')->defaultValue('black')->end() + ->scalarNode('secondary')->defaultValue('#AAA')->end() + ->scalarNode('button')->defaultValue('white')->end() + ->scalarNode('error_message')->defaultValue('#b90000')->end() + ->end() + ->end() + ->arrayNode('background')->addDefaultsIfNotSet() + ->children() + ->scalarNode('page')->defaultValue('#eee')->end() + ->scalarNode('container')->defaultValue('white')->end() + ->scalarNode('button')->defaultValue('#626365')->end() + ->scalarNode('button_hover')->defaultValue('#333')->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('text')->addDefaultsIfNotSet() + ->children() + ->arrayNode('captcha_wall')->addDefaultsIfNotSet() + ->children() + ->scalarNode('tab_title')->defaultValue('Oops..')->end() + ->scalarNode('title')->defaultValue('Hmm, sorry but...')->end() + ->scalarNode('subtitle')->defaultValue('Please complete the security check.')->end() + ->scalarNode('refresh_image_link')->defaultValue('refresh image')->end() + ->scalarNode('captcha_placeholder')->defaultValue('Type here...')->end() + ->scalarNode('send_button')->defaultValue('CONTINUE')->end() + ->scalarNode('error_message')->defaultValue('Please try again.')->end() + ->scalarNode('footer')->defaultValue('')->end() + ->end() + ->end() + ->arrayNode('ban_wall')->addDefaultsIfNotSet() + ->children() + ->scalarNode('tab_title')->defaultValue('Oops..')->end() + ->scalarNode('title')->defaultValue('🤭 Oh!')->end() + ->scalarNode('subtitle')->defaultValue($defaultSubtitle)->end() + ->scalarNode('footer')->defaultValue('')->end() + ->end() + ->end() + ->end() + ->end() + ->booleanNode('hide_mentions')->defaultValue(false)->end() + ->scalarNode('custom_css')->defaultValue('')->end() + ->end(); + } + /** * Bouncer settings * * @param NodeDefinition|ArrayNodeDefinition $rootNode * @return void - * @throws InvalidArgumentException */ private function addBouncerNodes($rootNode) { $rootNode->children() ->enumNode('bouncing_level') - ->values( - [ - Constants::BOUNCING_LEVEL_DISABLED, - Constants::BOUNCING_LEVEL_NORMAL, - Constants::BOUNCING_LEVEL_FLEX - ] - ) - ->defaultValue(Constants::BOUNCING_LEVEL_NORMAL) - ->end() - ->enumNode('max_remediation_level') - ->values(Constants::ORDERED_REMEDIATIONS) - ->defaultValue(Constants::REMEDIATION_BAN) - ->end() - ->enumNode('fallback_remediation') - ->values(Constants::ORDERED_REMEDIATIONS) - ->defaultValue(Constants::REMEDIATION_CAPTCHA) + ->values( + [ + Constants::BOUNCING_LEVEL_DISABLED, + Constants::BOUNCING_LEVEL_NORMAL, + Constants::BOUNCING_LEVEL_FLEX + ] + ) + ->defaultValue(Constants::BOUNCING_LEVEL_NORMAL) ->end() ->arrayNode('trust_ip_forward_array') - ->arrayPrototype() - ->scalarPrototype()->end() - ->end() + ->arrayPrototype() + ->scalarPrototype()->end() + ->end() ->end() ->arrayNode('excluded_uris') - ->scalarPrototype()->end() + ->scalarPrototype()->end() ->end() - ->end(); + ->end(); } /** @@ -86,12 +162,10 @@ private function addBouncerNodes($rootNode) * * @param NodeDefinition|ArrayNodeDefinition $rootNode * @return void - * @throws InvalidArgumentException */ private function addCacheNodes($rootNode) { $rootNode->children() - ->booleanNode('stream_mode')->defaultValue(false)->end() ->enumNode('cache_system') ->values( [ @@ -102,21 +176,9 @@ private function addCacheNodes($rootNode) ) ->defaultValue(Constants::CACHE_SYSTEM_PHPFS) ->end() - ->scalarNode('fs_cache_path')->end() - ->scalarNode('redis_dsn')->end() - ->scalarNode('memcached_dsn')->end() - ->integerNode('clean_ip_cache_duration') - ->min(1)->defaultValue(Constants::CACHE_EXPIRATION_FOR_CLEAN_IP) - ->end() - ->integerNode('bad_ip_cache_duration') - ->min(1)->defaultValue(Constants::CACHE_EXPIRATION_FOR_BAD_IP) - ->end() ->integerNode('captcha_cache_duration') ->min(1)->defaultValue(Constants::CACHE_EXPIRATION_FOR_CAPTCHA) ->end() - ->integerNode('geolocation_cache_duration') - ->min(1)->defaultValue(Constants::CACHE_EXPIRATION_FOR_GEO) - ->end() ->end(); } @@ -125,34 +187,10 @@ private function addCacheNodes($rootNode) * * @param NodeDefinition|ArrayNodeDefinition $rootNode * @return void - * @throws InvalidArgumentException */ private function addConnectionNodes($rootNode) { $rootNode->children() - ->enumNode('auth_type') - ->values( - [ - Constants::AUTH_KEY, - Constants::AUTH_TLS, - ] - ) - ->defaultValue(Constants::AUTH_KEY) - ->end() - ->scalarNode('api_key')->end() - ->scalarNode('api_url')->defaultValue(Constants::DEFAULT_LAPI_URL)->end() - ->scalarNode('api_user_agent')->defaultValue(Constants::BASE_USER_AGENT)->end() - ->scalarNode('tls_cert_path') - ->info('Absolute path to the Bouncer certificate')->defaultValue('') - ->end() - ->scalarNode('tls_key_path') - ->info('Absolute path to the Bouncer key')->defaultValue('') - ->end() - ->scalarNode('tls_ca_cert_path') - ->info('Absolute path to the CA used to process TLS handshake')->defaultValue('') - ->end() - ->booleanNode('tls_verify_peer')->defaultValue(false)->end() - ->integerNode('api_timeout')->defaultValue(Constants::API_TIMEOUT)->end() ->booleanNode('use_curl')->defaultValue(false)->end() ->end(); } @@ -172,86 +210,7 @@ private function addDebugNodes($rootNode) ->booleanNode('disable_prod_log')->defaultValue(false)->end() ->scalarNode('log_directory_path')->end() ->booleanNode('display_errors')->defaultValue(false)->end() - ->end(); - } - - /** - * Geolocation settings - * - * @param NodeDefinition|ArrayNodeDefinition $rootNode - * @return void - * @throws InvalidArgumentException - */ - private function addGeolocationNodes($rootNode) - { - $rootNode->children() - ->arrayNode('geolocation') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('save_result') - ->defaultTrue() - ->end() - ->booleanNode('enabled') - ->defaultFalse() - ->end() - ->enumNode('type') - ->defaultValue(Constants::GEOLOCATION_TYPE_MAXMIND) - ->values([Constants::GEOLOCATION_TYPE_MAXMIND]) - ->end() - ->arrayNode(Constants::GEOLOCATION_TYPE_MAXMIND) - ->addDefaultsIfNotSet() - ->children() - ->enumNode('database_type') - ->defaultValue(Constants::MAXMIND_COUNTRY) - ->values([Constants::MAXMIND_COUNTRY, Constants::MAXMIND_CITY]) - ->end() - ->scalarNode('database_path') - ->cannotBeEmpty() - ->end() - ->end() - ->end() - ->end() - ->end() - ->end(); + ->end(); } - /** - * Conditional validation - * - * @param NodeDefinition|ArrayNodeDefinition $rootNode - * @return void - * @throws InvalidArgumentException - * @throws RuntimeException - */ - private function validate($rootNode) - { - $rootNode->validate() - ->ifTrue(function (array $v) { - if ($v['auth_type'] === Constants::AUTH_KEY && empty($v['api_key'])) { - return true; - } - return false; - }) - ->thenInvalid('Api key is required as auth type is api_key') - ->end() - ->validate() - ->ifTrue(function (array $v) { - if ($v['auth_type'] === Constants::AUTH_TLS) { - return empty($v['tls_cert_path']) || empty($v['tls_key_path']); - } - return false; - }) - ->thenInvalid('Bouncer certificate and key paths are required for tls authentification.') - ->end() - ->validate() - ->ifTrue(function (array $v) { - if ($v['auth_type'] === Constants::AUTH_TLS && $v['tls_verify_peer'] === true) { - return empty($v['tls_ca_cert_path']); - } - - return false; - }) - ->thenInvalid('CA path is required for tls authentification with verify_peer.') - ->end(); - } } diff --git a/src/Geolocation.php b/src/Geolocation.php deleted file mode 100644 index 2b50170..0000000 --- a/src/Geolocation.php +++ /dev/null @@ -1,179 +0,0 @@ - '', 'not_found' => '', 'error' => '']; - - /** - * @throws CacheException - * @throws BouncerException - * @throws InvalidArgumentException - */ - public function clearGeolocationCache(string $ip, ApiCache $apiCache): void - { - $variables = ['crowdsec_geolocation_country', 'crowdsec_geolocation_not_found']; - $apiCache->unsetIpVariables(Constants::CACHE_TAG_GEO, $variables, $ip); - } - - /** - * Retrieve country from a geo-localised IP. - * - * @param array $geolocConfig - * @param string $ip - * @param ApiCache $apiCache - * @return array - * @throws BouncerException - * @throws CacheException - * @throws InvalidArgumentException - * @throws Exception - */ - public function getCountryResult(array $geolocConfig, string $ip, ApiCache $apiCache): array - { - $result = $this->resultTemplate; - $saveInCache = !empty($geolocConfig['save_result']); - if ($saveInCache) { - $cachedVariables = $apiCache->getIpVariables( - Constants::CACHE_TAG_GEO, - ['crowdsec_geolocation_country', 'crowdsec_geolocation_not_found'], - $ip - ); - $country = $cachedVariables['crowdsec_geolocation_country'] ?? null; - $notFoundError = $cachedVariables['crowdsec_geolocation_not_found'] ?? null; - if ($country) { - $result['country'] = $country; - - return $result; - } elseif ($notFoundError) { - $result['not_found'] = $notFoundError; - - return $result; - } - } - if (Constants::GEOLOCATION_TYPE_MAXMIND !== $geolocConfig['type']) { - throw new BouncerException('Unknown Geolocation type:' . $geolocConfig['type']); - } - $configPath = $geolocConfig[Constants::GEOLOCATION_TYPE_MAXMIND]; - $result = $this->getMaxMindCountry($ip, $configPath['database_type'], $configPath['database_path']); - - if ($saveInCache) { - if (!empty($result['country'])) { - $apiCache->setIpVariables( - Constants::CACHE_TAG_GEO, - ['crowdsec_geolocation_country' => $result['country']], - $ip - ); - } elseif (!empty($result['not_found'])) { - $apiCache->setIpVariables( - Constants::CACHE_TAG_GEO, - ['crowdsec_geolocation_not_found' => $result['not_found']], - $ip - ); - } - } - - return $result; - } - - /** - * Retrieve country ISO code alpha-2 if exists for the specified IP - * - * @param array $geolocConfig - * @param string $ip - * @param ApiCache $apiCache - * @param LoggerInterface $logger - * @return string|null - * @throws BouncerException - * @throws CacheException - * @throws InvalidArgumentException - */ - public function getCountryToQuery( - array $geolocConfig, - string $ip, - ApiCache $apiCache, - LoggerInterface $logger - ): ?string { - $countryToQuery = null; - $countryResult = $this->getCountryResult($geolocConfig, $ip, $apiCache); - if (!empty($countryResult['country'])) { - $countryToQuery = $countryResult['country']; - $logger->debug('', ['type' => 'GEOLOCALISED_COUNTRY', 'ip' => $ip, 'country' => $countryToQuery]); - } elseif (!empty($countryResult['not_found'])) { - $logger->warning('', [ - 'type' => 'IP_NOT_FOUND_WHILE_GETTING_GEOLOC_COUNTRY', - 'ip' => $ip, - 'error' => $countryResult['not_found'], - ]); - } elseif (!empty($countryResult['error'])) { - $logger->warning('', [ - 'type' => 'ERROR_WHILE_GETTING_GEOLOC_COUNTRY', - 'ip' => $ip, - 'error' => $countryResult['error'], - ]); - } - - return $countryToQuery; - } - - /** - * Retrieve a country from a MaxMind database. - * - * @throws Exception - */ - private function getMaxMindCountry(string $ip, string $databaseType, string $databasePath): array - { - if (!isset($this->maxmindCountry[$ip][$databaseType][$databasePath])) { - $result = $this->resultTemplate; - try { - $reader = new Reader($databasePath); - switch ($databaseType) { - case Constants::MAXMIND_COUNTRY: - $record = $reader->country($ip); - break; - case Constants::MAXMIND_CITY: - $record = $reader->city($ip); - break; - default: - throw new BouncerException("Unknown MaxMind database type:$databaseType"); - } - $result['country'] = $record->country->isoCode; - } catch (AddressNotFoundException $e) { - $result['not_found'] = $e->getMessage(); - } catch (Exception $e) { - $result['error'] = $e->getMessage(); - } - - $this->maxmindCountry[$ip][$databaseType][$databasePath] = $result; - } - - return $this->maxmindCountry[$ip][$databaseType][$databasePath]; - } -} diff --git a/src/Remediation.php b/src/Remediation.php deleted file mode 100644 index 22d4a3c..0000000 --- a/src/Remediation.php +++ /dev/null @@ -1,66 +0,0 @@ - $remediation) { - $remediationsWithPriorities[$key] = self::addPriority($remediation); - } - - // Sort by priorities. - /** @var callable $compareFunction */ - $compareFunction = self::class . '::comparePriorities'; - usort($remediationsWithPriorities, $compareFunction); - - return $remediationsWithPriorities; - } -} diff --git a/src/RestClient/AbstractClient.php b/src/RestClient/AbstractClient.php deleted file mode 100644 index a16f118..0000000 --- a/src/RestClient/AbstractClient.php +++ /dev/null @@ -1,73 +0,0 @@ -logger = $logger; - $this->configs = $configs; - if (empty($this->configs['api_url'])) { - throw new BouncerException('Api url is required'); - } - $this->baseUri = $this->configs['api_url']; - $this->timeout = $this->configs['api_timeout'] ?? Constants::API_TIMEOUT; - if (empty($this->configs['headers'])) { - throw new BouncerException('Headers are required'); - } - $this->headers = $this->configs['headers']; - if (empty($this->headers['User-Agent'])) { - throw new BouncerException('User-Agent header required'); - } - - $this->logger->debug('', [ - 'type' => 'REST_CLIENT_INIT', - 'base_uri' => $this->baseUri, - 'timeout' => $this->timeout, - 'user_agent' => $this->headers['User-Agent'], - ]); - } - - /** - * Send an HTTP request and parse its JSON result if any. - */ - abstract public function request( - string $endpoint, - array $queryParams = null, - array $bodyParams = null, - string $method = 'GET', - array $headers = null - ): ?array; -} diff --git a/src/RestClient/Curl.php b/src/RestClient/Curl.php deleted file mode 100644 index 58317c5..0000000 --- a/src/RestClient/Curl.php +++ /dev/null @@ -1,142 +0,0 @@ -createOptions($endpoint, $queryParams, $bodyParams, $method, $headers ?: $this->headers); - - curl_setopt_array($handle, $curlOptions); - - $response = $this->exec($handle); - - if (false === $response) { - throw new BouncerException('Unexpected CURL call failure: ' . curl_error($handle)); - } - - $statusCode = $this->getResponseHttpCode($handle); - if (empty($statusCode)) { - throw new BouncerException('Unexpected empty response http code'); - } - - curl_close($handle); - - if ($statusCode < 200 || $statusCode >= 300) { - $message = "Unexpected response status from $this->baseUri$endpoint: $statusCode\n" . $response; - throw new BouncerException($message); - } - - return json_decode($response, true); - } - - /** - * @return bool|string - */ - protected function exec($handle) - { - return curl_exec($handle); - } - - /** - * @return mixed - */ - protected function getResponseHttpCode($handle) - { - return curl_getinfo($handle, \CURLINFO_HTTP_CODE); - } - - /** - * Retrieve Curl options. - */ - private function createOptions( - string $endpoint, - ?array $queryParams, - ?array $bodyParams, - string $method, - array $headers - ): array { - $url = $this->baseUri . $endpoint; - - $options = [ - \CURLOPT_HEADER => false, - \CURLOPT_RETURNTRANSFER => true, - \CURLOPT_USERAGENT => $headers['User-Agent'], - ]; - - $options[\CURLOPT_HTTPHEADER] = []; - foreach ($headers as $key => $values) { - foreach (\is_array($values) ? $values : [$values] as $value) { - $options[\CURLOPT_HTTPHEADER][] = sprintf('%s:%s', $key, $value); - } - } - $options[\CURLOPT_SSL_VERIFYPEER] = false; - if (isset($this->configs['auth_type']) && Constants::AUTH_TLS === $this->configs['auth_type']) { - $verifyPeer = $this->configs['tls_verify_peer'] ?? true; - $options[\CURLOPT_SSL_VERIFYPEER] = $verifyPeer; - // The --cert option - $options[\CURLOPT_SSLCERT] = $this->configs['tls_cert_path'] ?? ''; - // The --key option - $options[\CURLOPT_SSLKEY] = $this->configs['tls_key_path'] ?? ''; - if ($verifyPeer) { - // The --cacert option - $options[\CURLOPT_CAINFO] = $this->configs['tls_ca_cert_path'] ?? ''; - } - } - $this->updateOptionsByMethod($options, $url, $method, $queryParams, $bodyParams); - - $options[\CURLOPT_URL] = $url; - if ($this->timeout > 0) { - $options[\CURLOPT_TIMEOUT] = $this->timeout; - } - - return $options; - } - - private function updateOptionsByMethod( - array &$options, - string &$url, - string $method, - ?array $queryParams, - ?array $bodyParams - ): void { - if ('POST' === strtoupper($method)) { - $parameters = $bodyParams; - $options[\CURLOPT_POST] = true; - $options[\CURLOPT_CUSTOMREQUEST] = 'POST'; - $options[\CURLOPT_POSTFIELDS] = json_encode($parameters); - } elseif ('GET' === strtoupper($method)) { - $parameters = $queryParams; - $options[\CURLOPT_POST] = false; - $options[\CURLOPT_CUSTOMREQUEST] = 'GET'; - $options[\CURLOPT_HTTPGET] = true; - - if (!empty($parameters)) { - $url .= strpos($url, '?') ? '&' : '?'; - $url .= http_build_query($parameters); - } - } elseif ('DELETE' === strtoupper($method)) { - $options[\CURLOPT_POST] = false; - $options[\CURLOPT_CUSTOMREQUEST] = 'DELETE'; - } - } -} diff --git a/src/RestClient/FileGetContents.php b/src/RestClient/FileGetContents.php deleted file mode 100644 index 80d3d0d..0000000 --- a/src/RestClient/FileGetContents.php +++ /dev/null @@ -1,113 +0,0 @@ -headerString = $this->convertHeadersToString($this->headers); - } - - /** - * Send an HTTP request using the file_get_contents and parse its JSON result if any. - * - * @throws BouncerException - */ - public function request( - string $endpoint, - array $queryParams = null, - array $bodyParams = null, - string $method = 'GET', - array $headers = null - ): ?array { - if ($queryParams) { - $endpoint .= '?' . http_build_query($queryParams); - } - - $config = $this->createConfig($bodyParams, $method, $headers); - $context = stream_context_create($config); - - $this->logger->debug('', [ - 'type' => 'HTTP CALL', - 'method' => $method, - 'uri' => $this->baseUri . $endpoint, - 'content' => 'POST' === $method ? $config['http']['content'] ?? null : null, - // 'header' => $header, # Do not display header to avoid logging sensible data - ]); - - $response = file_get_contents($this->baseUri . $endpoint, false, $context); - if (false === $response) { - throw new BouncerException('Unexpected HTTP call failure.'); - } - $parts = explode(' ', $http_response_header[0]); - $status = 0; - if (\count($parts) > 1) { - $status = (int) $parts[1]; - } - - if ($status < 200 || $status >= 300) { - $message = "Unexpected response status from $this->baseUri$endpoint: $status\n" . $response; - throw new BouncerException($message); - } - - return json_decode($response, true); - } - - /** - * Convert a key-value array of headers to the official HTTP header string. - */ - private function convertHeadersToString(array $headers): string - { - $builtHeaderString = ''; - foreach ($headers as $key => $value) { - $builtHeaderString .= "$key: $value\r\n"; - } - - return $builtHeaderString; - } - - private function createConfig( - array $bodyParams = null, - string $method = 'GET', - array $headers = null - ): array { - $header = $headers ? $this->convertHeadersToString($headers) : $this->headerString; - $config = [ - 'http' => [ - 'method' => $method, - 'header' => $header, - 'timeout' => $this->timeout, - 'ignore_errors' => true, - ], - ]; - $config['ssl'] = ['verify_peer' => false]; - if (isset($this->configs['auth_type']) && Constants::AUTH_TLS === $this->configs['auth_type']) { - $verifyPeer = $this->configs['tls_verify_peer'] ?? true; - $config['ssl'] = [ - 'verify_peer' => $verifyPeer, - 'local_cert' => $this->configs['tls_cert_path'] ?? '', - 'local_pk' => $this->configs['tls_key_path'] ?? '', - ]; - if ($verifyPeer) { - $config['ssl']['cafile'] = $this->configs['tls_ca_cert_path'] ?? ''; - } - } - - if ($bodyParams) { - $config['http']['content'] = json_encode($bodyParams); - } - - return $config; - } -} diff --git a/src/StandaloneBounce.php b/src/StandaloneBounce.php deleted file mode 100644 index 0e3829f..0000000 --- a/src/StandaloneBounce.php +++ /dev/null @@ -1,360 +0,0 @@ - $this->getBoolSettings('hide_mentions'), - 'color' => $this->getColorConfigs(), - 'text' => [ - 'ban_wall' => [ - 'tab_title' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_ban_wall_tab_title'), - \ENT_QUOTES - ), - 'title' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_ban_wall_title'), - \ENT_QUOTES - ), - 'subtitle' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_ban_wall_subtitle'), - \ENT_QUOTES - ), - 'footer' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_ban_wall_footer'), - \ENT_QUOTES - ), - ], - ], - 'custom_css' => htmlspecialchars_decode($this->getStringSettings('theme_custom_css'), \ENT_QUOTES), - ]; - } - - /** - * Get the bouncer instance. - * - * @throws CacheException - * @throws ErrorException - * @throws \Psr\Cache\InvalidArgumentException|BouncerException - */ - public function getBouncerInstance(array $settings): Bouncer - { - $this->settings = array_merge($this->settings, $settings); - $apiUserAgent = 'Standalone CrowdSec PHP Bouncer/' . Constants::VERSION; - - $this->settings['api_user_agent'] = $apiUserAgent; - $bouncerConfigs = $this->prepareBouncerConfigs(); - - // Instantiate bouncer - $this->bouncer = new Bouncer($bouncerConfigs, $this->logger); - - return $this->bouncer; - } - - /** - * @return array ['hide_crowdsec_mentions': bool, color:[text:['primary' : string, 'secondary' : string, 'button' : - * string, 'error_message : string' ...]]] (returns an array of option required to build the captcha wall - * template) - */ - public function getCaptchaWallOptions(): array - { - return [ - 'hide_crowdsec_mentions' => $this->getBoolSettings('hide_mentions'), - 'color' => $this->getColorConfigs(), - 'text' => [ - 'captcha_wall' => [ - 'tab_title' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_tab_title'), - \ENT_QUOTES - ), - 'title' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_title'), - \ENT_QUOTES - ), - 'subtitle' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_subtitle'), - \ENT_QUOTES - ), - 'refresh_image_link' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_refresh_image_link'), - \ENT_QUOTES - ), - 'captcha_placeholder' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_captcha_placeholder'), - \ENT_QUOTES - ), - 'send_button' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_send_button'), - \ENT_QUOTES - ), - 'error_message' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_error_message'), - \ENT_QUOTES - ), - 'footer' => htmlspecialchars_decode( - $this->getStringSettings('theme_text_captcha_wall_footer'), - \ENT_QUOTES - ), - ], - ], - 'custom_css' => $this->getStringSettings('theme_custom_css'), - ]; - } - - /** - * The current HTTP method. - */ - public function getHttpMethod(): string - { - return $_SERVER['REQUEST_METHOD'] ?? ""; - } - - /** - * @param string $name Ex: "X-Forwarded-For" - */ - public function getHttpRequestHeader(string $name): ?string - { - $headerName = 'HTTP_' . str_replace('-', '_', strtoupper($name)); - if (!\array_key_exists($headerName, $_SERVER)) { - return null; - } - - return is_string($_SERVER[$headerName]) ? $_SERVER[$headerName] : null; - } - - /** - * Get the value of a posted field. - */ - public function getPostedVariable(string $name): ?string - { - if (!isset($_POST[$name])) { - return null; - } - - return is_string($_POST[$name]) ? $_POST[$name] : null; - } - - /** - * @return string The current IP, even if it's the IP of a proxy - */ - public function getRemoteIp(): string - { - return $_SERVER['REMOTE_ADDR'] ?? ""; - } - - /** - * @return array [[string, string], ...] Returns IP ranges to trust as proxies as an array of comparables ip bounds - */ - public function getTrustForwardedIpBoundsList(): array - { - return $this->getArraySettings('trust_ip_forward_array'); - } - - /** - * Initialize the bouncer. - * - * @throws CacheException - * @throws ErrorException - * @throws \Psr\Cache\InvalidArgumentException|BouncerException - */ - public function init(array $configs): Bouncer - { - // Convert array of string to array of array with comparable IPs - if (\is_array(($configs['trust_ip_forward_array']))) { - $forwardConfigs = $configs['trust_ip_forward_array']; - $finalForwardConfigs = []; - foreach ($forwardConfigs as $forwardConfig) { - if (\is_string($forwardConfig)) { - $parsedString = Factory::parseAddressString($forwardConfig, 3); - if (!empty($parsedString)) { - $comparableValue = $parsedString->getComparableString(); - $finalForwardConfigs[] = [$comparableValue, $comparableValue]; - } - } elseif (\is_array($forwardConfig)) { - $finalForwardConfigs[] = $forwardConfig; - } - } - $configs['trust_ip_forward_array'] = $finalForwardConfigs; - } - $this->settings = $configs; - - return $this->getBouncerInstance($this->settings); - } - - public function initLogger(array $configs): void - { - $this->initLoggerHelper($configs, 'php_standalone_bouncer'); - } - - /** - * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. - * - * @param array $configs - * @return bool - * @throws BouncerException - * @throws CacheException - * @throws ErrorException - * @throws \Psr\Cache\CacheException - * @throws \Psr\Cache\InvalidArgumentException - */ - public function safelyBounce(array $configs): bool - { - $result = false; - set_error_handler(function ($errno, $errstr) { - throw new BouncerException("$errstr (Error level: $errno)"); - }); - try { - $this->settings = $configs; - $this->initLogger($configs); - if ($this->shouldBounceCurrentIp()) { - $this->init($configs); - $this->run(); - $result = true; - } - } catch (Exception $e) { - if ($this->logger) { - $this->logger->error('', [ - 'type' => 'EXCEPTION_WHILE_BOUNCING', - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - } - if (!empty($configs['display_errors'])) { - throw $e; - } - } - restore_error_handler(); - - return $result; - } - - /** - * Send HTTP response. - * @throws BouncerException - */ - public function sendResponse(?string $body, int $statusCode = 200): void - { - switch ($statusCode) { - case 200: - header('HTTP/1.0 200 OK'); - break; - case 401: - header('HTTP/1.0 401 Unauthorized'); - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); - break; - case 403: - header('HTTP/1.0 403 Forbidden'); - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); - break; - default: - throw new BouncerException("Unhandled code $statusCode"); - } - if (null !== $body) { - echo $body; - } - exit(); - } - - /** - * If the current IP should be bounced or not, matching custom business rules. - */ - public function shouldBounceCurrentIp(): bool - { - $excludedURIs = $this->getArraySettings('excluded_uris'); - if (isset($_SERVER['REQUEST_URI']) && \in_array($_SERVER['REQUEST_URI'], $excludedURIs)) { - if ($this->logger) { - $this->logger->debug('', [ - 'type' => 'SHOULD_NOT_BOUNCE', - 'message' => 'This URI is excluded from bouncing: ' . $_SERVER['REQUEST_URI'], - ]); - } - - return false; - } - $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getStringSettings('bouncing_level')); - if ($bouncingDisabled) { - if ($this->logger) { - $this->logger->debug('', [ - 'type' => 'SHOULD_NOT_BOUNCE', - 'message' => Constants::BOUNCING_LEVEL_DISABLED, - ]); - } - - return false; - } - - return true; - } - - private function getColorConfigs(): array - { - return [ - 'text' => [ - 'primary' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_text_primary'), - \ENT_QUOTES - ), - 'secondary' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_text_secondary'), - \ENT_QUOTES - ), - 'button' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_text_button'), - \ENT_QUOTES - ), - 'error_message' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_text_error_message'), - \ENT_QUOTES - ), - ], - 'background' => [ - 'page' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_background_page'), - \ENT_QUOTES - ), - 'container' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_background_container'), - \ENT_QUOTES - ), - 'button' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_background_button'), - \ENT_QUOTES - ), - 'button_hover' => htmlspecialchars_decode( - $this->getStringSettings('theme_color_background_button_hover'), - \ENT_QUOTES - ), - ], - ]; - } -} diff --git a/src/StandaloneBouncer.php b/src/StandaloneBouncer.php new file mode 100644 index 0000000..891fe80 --- /dev/null +++ b/src/StandaloneBouncer.php @@ -0,0 +1,198 @@ +logger = $logger ?:new FileLog($configs, 'php_standalone_bouncer'); + $configs = $this->handleTrustedIpsConfig($configs); + $configs['user_agent_version'] = Constants::VERSION; + $configs['user_agent_suffix'] = 'Standalone'; + $client = $this->handleClient($configs, $this->logger); + $cache = $this->handleCache($configs, $this->logger); + $remediation = new LapiRemediation($configs, $client, $cache, $this->logger); + + parent::__construct($configs, $remediation, $this->logger); + } + + /** + * The current HTTP method. + */ + public function getHttpMethod(): string + { + return $_SERVER['REQUEST_METHOD'] ?? ""; + } + + /** + * @param string $name Ex: "X-Forwarded-For" + */ + public function getHttpRequestHeader(string $name): ?string + { + $headerName = 'HTTP_' . str_replace('-', '_', strtoupper($name)); + if (!\array_key_exists($headerName, $_SERVER)) { + return null; + } + + return is_string($_SERVER[$headerName]) ? $_SERVER[$headerName] : null; + } + + /** + * Get the value of a posted field. + */ + public function getPostedVariable(string $name): ?string + { + if (!isset($_POST[$name])) { + return null; + } + + return is_string($_POST[$name]) ? $_POST[$name] : null; + } + + /** + * @return string The current IP, even if it's the IP of a proxy + */ + public function getRemoteIp(): string + { + return $_SERVER['REMOTE_ADDR'] ?? ""; + } + + /** + * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. + * + * @param array $configs + * @return bool + */ + public function safelyBounce(): bool + { + $result = false; + set_error_handler(function ($errno, $errstr) { + throw new BouncerException("$errstr (Error level: $errno)"); + }); + try { + if ($this->shouldBounceCurrentIp()) { + $this->bounceCurrentIp(); + $result = true; + } + } catch (Exception $e) { + $this->logger->error('Something went wrong during bouncing', [ + 'type' => 'EXCEPTION_WHILE_BOUNCING', + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + if (!empty($this->getBoolConfig('display_errors'))) { + throw $e; + } + } + restore_error_handler(); + + return $result; + } + + /** + * Send HTTP response. + */ + public function sendResponse(?string $body, int $statusCode = 200): void + { + switch ($statusCode) { + case 200: + header('HTTP/1.0 200 OK'); + break; + case 401: + header('HTTP/1.0 401 Unauthorized'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + break; + case 403: + header('HTTP/1.0 403 Forbidden'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + break; + default: + throw new BouncerException("Unhandled code $statusCode"); + } + if (null !== $body) { + echo $body; + } + exit(); + } + + /** + * If the current IP should be bounced or not, matching custom business rules. + */ + public function shouldBounceCurrentIp(): bool + { + $excludedURIs = $this->getArrayConfig('excluded_uris'); + if (isset($_SERVER['REQUEST_URI']) && \in_array($_SERVER['REQUEST_URI'], $excludedURIs)) { + $this->logger->debug('Will not bounce as URI is excluded', [ + 'type' => 'SHOULD_NOT_BOUNCE', + 'message' => 'This URI is excluded from bouncing: ' . $_SERVER['REQUEST_URI'], + ]); + + return false; + } + $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getStringConfig('bouncing_level')); + if ($bouncingDisabled) { + $this->logger->debug('Will not bounce as bouncing is disabled', [ + 'type' => 'SHOULD_NOT_BOUNCE', + 'message' => Constants::BOUNCING_LEVEL_DISABLED, + ]); + + return false; + } + + return true; + } + + + /** + * Initialize the bouncer. + * + */ + private function handleTrustedIpsConfig(array $configs): array + { + // Convert array of string to array of array with comparable IPs + if (isset($configs['trust_ip_forward_array']) && \is_array(($configs['trust_ip_forward_array']))) { + $forwardConfigs = $configs['trust_ip_forward_array']; + $finalForwardConfigs = []; + foreach ($forwardConfigs as $forwardConfig) { + if (\is_string($forwardConfig)) { + $parsedString = Factory::parseAddressString($forwardConfig, 3); + if (!empty($parsedString)) { + $comparableValue = $parsedString->getComparableString(); + $finalForwardConfigs[] = [$comparableValue, $comparableValue]; + } + } elseif (\is_array($forwardConfig)) { + $finalForwardConfigs[] = $forwardConfig; + } + } + $configs['trust_ip_forward_array'] = $finalForwardConfigs; + } + + return $configs; + } +} diff --git a/src/Template.php b/src/Template.php index 154d22d..9a6612f 100644 --- a/src/Template.php +++ b/src/Template.php @@ -30,9 +30,6 @@ class Template * @param string $path * @param string $templatesDir * @param array $options - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError */ public function __construct(string $path, string $templatesDir = Constants::TEMPLATES_DIR, array $options = []) { diff --git a/src/TemplateConfiguration.php b/src/TemplateConfiguration.php deleted file mode 100644 index 2acdd40..0000000 --- a/src/TemplateConfiguration.php +++ /dev/null @@ -1,90 +0,0 @@ -getRootNode(); - $rootNode - ->children() - ->arrayNode('color') - ->addDefaultsIfNotSet() - ->children() - ->arrayNode('text') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('primary')->defaultValue('black')->end() - ->scalarNode('secondary')->defaultValue('#AAA')->end() - ->scalarNode('button')->defaultValue('white')->end() - ->scalarNode('error_message')->defaultValue('#b90000')->end() - ->end() - ->end() - ->arrayNode('background') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('page')->defaultValue('#eee')->end() - ->scalarNode('container')->defaultValue('white')->end() - ->scalarNode('button')->defaultValue('#626365')->end() - ->scalarNode('button_hover')->defaultValue('#333')->end() - ->end() - ->end() - ->end() - ->end() - ->arrayNode('text') - ->addDefaultsIfNotSet() - ->children() - ->arrayNode('captcha_wall') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('tab_title')->defaultValue('Oops..')->end() - ->scalarNode('title')->defaultValue('Hmm, sorry but...')->end() - ->scalarNode('subtitle')->defaultValue('Please complete the security check.')->end() - ->scalarNode('refresh_image_link')->defaultValue('refresh image')->end() - ->scalarNode('captcha_placeholder')->defaultValue('Type here...')->end() - ->scalarNode('send_button')->defaultValue('CONTINUE')->end() - ->scalarNode('error_message')->defaultValue('Please try again.')->end() - ->scalarNode('footer')->defaultValue('')->end() - ->end() - ->end() - ->arrayNode('ban_wall') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('tab_title')->defaultValue('Oops..')->end() - ->scalarNode('title')->defaultValue('🤭 Oh!')->end() - ->scalarNode('subtitle')->defaultValue($defaultSubtitle)->end() - ->scalarNode('footer')->defaultValue('')->end() - ->end() - ->end() - ->end() - ->end() - ->booleanNode('hide_crowdsec_mentions')->defaultValue(false)->end() - ->scalarNode('custom_css')->defaultValue(null)->end() - ->end(); - - return $treeBuilder; - } -} diff --git a/src/templates/ban.html.twig b/src/templates/ban.html.twig index 9fab80c..2311b0d 100644 --- a/src/templates/ban.html.twig +++ b/src/templates/ban.html.twig @@ -11,7 +11,7 @@ {% if config.text.ban_wall.footer is not empty %} {% endif %} - {% if config.hide_crowdsec_mentions is empty %} + {% if config.hide_mentions is empty %} {% include('partials/mentions.html.twig') %} {% endif %} diff --git a/src/templates/captcha.html.twig b/src/templates/captcha.html.twig index 3b7816a..7858615 100644 --- a/src/templates/captcha.html.twig +++ b/src/templates/captcha.html.twig @@ -35,7 +35,7 @@ {% if config.text.ban_wall.footer is not empty %} {% endif %} - {% if config.hide_crowdsec_mentions is empty %} + {% if config.hide_mentions is empty %} {% include('partials/mentions.html.twig') %} {% endif %} diff --git a/tests/Integration/GeolocationTest.php b/tests/Integration/GeolocationTest.php index a891481..c4670de 100644 --- a/tests/Integration/GeolocationTest.php +++ b/tests/Integration/GeolocationTest.php @@ -4,14 +4,10 @@ namespace CrowdSecBouncer\Tests\Integration; -use CrowdSecBouncer\ApiCache; -use CrowdSecBouncer\ApiClient; -use CrowdSecBouncer\Bouncer; +use CrowdSecBouncer\StandaloneBouncer; use CrowdSecBouncer\Constants; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Symfony\Component\Cache\Adapter\PhpFilesAdapter; -use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GeolocationTest extends TestCase { @@ -23,12 +19,35 @@ final class GeolocationTest extends TestCase /** @var bool */ private $useCurl; + private function addTlsConfig(&$bouncerConfigs, $tlsPath) + { + $bouncerConfigs['tls_cert_path'] = $tlsPath . '/bouncer.pem'; + $bouncerConfigs['tls_key_path'] = $tlsPath . '/bouncer-key.pem'; + $bouncerConfigs['tls_ca_cert_path'] = $tlsPath . '/ca-chain.pem'; + $bouncerConfigs['tls_verify_peer'] = true; + } + protected function setUp(): void { + $this->useTls = (string) getenv('BOUNCER_TLS_PATH'); + $this->useCurl = (bool) getenv('USE_CURL'); $this->logger = TestHelpers::createLogger(); - $this->useCurl = (bool) getenv('USE_CURL'); - $this->watcherClient = new WatcherClient(['use_curl' => $this->useCurl], $this->logger); + $bouncerConfigs = [ + 'auth_type' => $this->useTls ? \CrowdSec\LapiClient\Constants::AUTH_TLS : Constants::AUTH_KEY, + 'api_key' => getenv('BOUNCER_KEY'), + 'api_url' => getenv('LAPI_URL'), + 'use_curl' => $this->useCurl, + 'user_agent_suffix' => 'testphpbouncer', + ]; + if ($this->useTls) { + $this->addTlsConfig($bouncerConfigs, $this->useTls); + } + + $this->configs = $bouncerConfigs; + $this->watcherClient = new WatcherClient($this->configs); + // Delete all decisions + $this->watcherClient->deleteAllDecisions(); } public function maxmindConfigProvider(): array @@ -44,7 +63,7 @@ private function handleMaxMindConfig(array $maxmindConfig): array } return [ - 'save_result' => false, + 'cache_duration' => 0, 'enabled' => true, 'type' => 'maxmind', 'maxmind' => [ @@ -56,7 +75,6 @@ private function handleMaxMindConfig(array $maxmindConfig): array /** * @group integration - * @covers \Bouncer * @dataProvider maxmindConfigProvider * @group ignore_ * @@ -77,12 +95,13 @@ public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindCon 'use_curl' => $this->useCurl, 'api_user_agent' => 'Unit test/' . Constants::BASE_USER_AGENT, 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, - 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR + 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR, + 'stream_mode' => false ]; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); - $cacheAdapter = $bouncer->getCacheAdapter(); + $cacheAdapter = $bouncer->getRemediationEngine()->getCacheStorage(); $cacheAdapter->clear(); $this->assertEquals( @@ -100,7 +119,7 @@ public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindCon // Disable Geolocation feature $geolocationConfig['enabled'] = false; $bouncerConfigs['geolocation'] = $geolocationConfig; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $cacheAdapter->clear(); $this->assertEquals( @@ -113,7 +132,7 @@ public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindCon $this->watcherClient->setSecondState(); $geolocationConfig['enabled'] = true; $bouncerConfigs['geolocation'] = $geolocationConfig; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $cacheAdapter->clear(); $this->assertEquals( @@ -154,8 +173,8 @@ public function testCanVerifyIpAndCountryWithMaxmindInStreamMode(array $maxmindC 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR ]; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); - $cacheAdapter = $bouncer->getCacheAdapter(); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); + $cacheAdapter= $bouncer->getRemediationEngine()->getCacheStorage(); $cacheAdapter->clear(); // Warm BlockList cache up diff --git a/tests/Integration/IpVerificationTest.php b/tests/Integration/IpVerificationTest.php index 42be209..d3ec1e9 100644 --- a/tests/Integration/IpVerificationTest.php +++ b/tests/Integration/IpVerificationTest.php @@ -6,6 +6,7 @@ use CrowdSecBouncer\Bouncer; use CrowdSecBouncer\Constants; +use CrowdSecBouncer\StandaloneBouncer; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -14,21 +15,37 @@ final class IpVerificationTest extends TestCase /** @var WatcherClient */ private $watcherClient; - /** @var LoggerInterface */ - private $logger; - /** @var bool */ private $useCurl; /** @var bool */ private $useTls; + /** + * @var LoggerInterface + */ + private $logger; protected function setUp(): void { + $this->useTls = (string) getenv('BOUNCER_TLS_PATH'); + $this->useCurl = (bool) getenv('USE_CURL'); $this->logger = TestHelpers::createLogger(); - $this->useCurl = (bool)getenv('USE_CURL'); - $this->useTls = (string)getenv('BOUNCER_TLS_PATH'); - $this->watcherClient = new WatcherClient(['use_curl' => $this->useCurl], $this->logger); + + $bouncerConfigs = [ + 'auth_type' => $this->useTls ? \CrowdSec\LapiClient\Constants::AUTH_TLS : Constants::AUTH_KEY, + 'api_key' => getenv('BOUNCER_KEY'), + 'api_url' => getenv('LAPI_URL'), + 'use_curl' => $this->useCurl, + 'user_agent_suffix' => 'testphpbouncer', + ]; + if ($this->useTls) { + $this->addTlsConfig($bouncerConfigs, $this->useTls); + } + + $this->configs = $bouncerConfigs; + $this->watcherClient = new WatcherClient($this->configs); + // Delete all decisions + $this->watcherClient->deleteAllDecisions(); } public function cacheAdapterConfigProvider(): array @@ -41,21 +58,21 @@ private function cacheAdapterCheck($cacheAdapter, $origCacheName) switch ($origCacheName) { case 'PhpFilesAdapter': $this->assertEquals( - 'Symfony\Component\Cache\Adapter\TagAwareAdapter', + 'CrowdSec\RemediationEngine\CacheStorage\PhpFiles', get_class($cacheAdapter), 'Tested adapter should be correct' ); break; case 'MemcachedAdapter': $this->assertEquals( - 'Symfony\Component\Cache\Adapter\MemcachedAdapter', + 'CrowdSec\RemediationEngine\CacheStorage\Memcached', get_class($cacheAdapter), 'Tested adapter should be correct' ); break; case 'RedisAdapter': $this->assertEquals( - 'Symfony\Component\Cache\Adapter\RedisTagAwareAdapter', + 'CrowdSec\RemediationEngine\CacheStorage\Redis', get_class($cacheAdapter), 'Tested adapter should be correct' ); @@ -88,20 +105,20 @@ public function testCanVerifyIpInLiveModeWithCacheSystem($cacheAdapterName, $ori 'api_key' => TestHelpers::getBouncerKey(), 'api_url' => TestHelpers::getLapiUrl(), 'use_curl' => $this->useCurl, - 'api_user_agent' => TestHelpers::UNIT_TEST_AGENT_PREFIX . '/' . Constants::BASE_USER_AGENT, 'cache_system' => $cacheAdapterName, 'redis_dsn' => getenv('REDIS_DSN'), 'memcached_dsn' => getenv('MEMCACHED_DSN'), - 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR + 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR, + 'stream_mode' => false ]; if ($this->useTls) { $this->addTlsConfig($bouncerConfigs, $this->useTls); } - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); // Test cache adapter - $cacheAdapter = $bouncer->getCacheAdapter(); + $cacheAdapter = $bouncer->getRemediationEngine()->getCacheStorage(); $cacheAdapter->clear(); $this->cacheAdapterCheck($cacheAdapter, $origCacheName); @@ -142,13 +159,13 @@ public function testCanVerifyIpInLiveModeWithCacheSystem($cacheAdapterName, $ori $this->assertEquals('ban', $remediation3rdCall); // Reconfigure the bouncer to set maximum remediation level to "captcha" - $bouncerConfigs['max_remediation_level'] = 'captcha'; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncerConfigs['bouncing_level'] = Constants::BOUNCING_LEVEL_FLEX; + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IP); $this->assertEquals('captcha', $cappedRemediation, 'The remediation for the banned IP should now be "captcha"'); // Reset the max remediation level to its origin state - unset($bouncerConfigs['max_remediation_level']); - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncerConfigs['bouncing_level'] = Constants::BOUNCING_LEVEL_NORMAL; + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $this->logger->info('', ['message' => 'set "Large IPV4 range banned" state']); $this->watcherClient->deleteAllDecisions(); @@ -163,7 +180,7 @@ public function testCanVerifyIpInLiveModeWithCacheSystem($cacheAdapterName, $ori $this->assertEquals( 'ban', $cappedRemediation, - 'The remediation for the banned IP with a too large range should now be "ban" as we are in live mode' + 'The remediation for the banned IPv4 range should be ban' ); $this->logger->info('', ['message' => 'set "IPV6 range banned" state']); @@ -179,7 +196,21 @@ public function testCanVerifyIpInLiveModeWithCacheSystem($cacheAdapterName, $ori $this->assertEquals( 'ban', $cappedRemediation, - 'The remediation for the banned IPV6 with a too large range should now be "ban" as we are in live mode' + 'The remediation for a banned IPv6 range should be ban in live mode' + ); + $this->watcherClient->deleteAllDecisions(); + $this->watcherClient->addDecision( + new \DateTime(), + '24h', + WatcherClient::HOURS24, + TestHelpers::BAD_IPV6, + 'ban' + ); + $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IPV6); + $this->assertEquals( + 'ban', + $cappedRemediation, + 'The remediation for a banned IPv6 should be ban' ); } @@ -208,9 +239,9 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o $this->addTlsConfig($bouncerConfigs, $this->useTls); } - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); // Test cache adapter - $cacheAdapter = $bouncer->getCacheAdapter(); + $cacheAdapter = $bouncer->getRemediationEngine()->getCacheStorage(); $cacheAdapter->clear(); $this->cacheAdapterCheck($cacheAdapter, $origCacheName); // As we are in stream mode, no live call should be done to the API. @@ -218,7 +249,6 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o $bouncer->refreshBlocklistCache(); - $this->logger->debug('', ['message' => 'Refresh the cache just after the warm up. Nothing should append.']); $bouncer->refreshBlocklistCache(); $this->assertEquals( @@ -228,12 +258,12 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o ); // Reconfigure the bouncer to set maximum remediation level to "captcha" - $bouncerConfigs['max_remediation_level'] = 'captcha'; - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncerConfigs['bouncing_level'] = Constants::BOUNCING_LEVEL_FLEX; + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IP); $this->assertEquals('captcha', $cappedRemediation, 'The remediation for the banned IP should now be "captcha"'); - unset($bouncerConfigs['max_remediation_level']); - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncerConfigs['bouncing_level'] = Constants::BOUNCING_LEVEL_NORMAL; + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $this->assertEquals( 'bypass', $bouncer->getRemediationForIp(TestHelpers::CLEAN_IP), @@ -288,7 +318,7 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o $bouncerConfigs['tls_verify_peer'] = true; } - $bouncer = new Bouncer($bouncerConfigs, $this->logger); + $bouncer = new StandaloneBouncer($bouncerConfigs, $this->logger); $this->assertEquals( 'ban', @@ -317,9 +347,9 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IP); $this->assertEquals( - 'bypass', + 'ban', $cappedRemediation, - 'The remediation for the banned IP with a too large range should now be "bypass" as we are in stream mode' + 'The remediation for the banned IP with a large range should be "ban" even in stream mode' ); $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IPV6); $this->assertEquals( diff --git a/tests/Integration/TestHelpers.php b/tests/Integration/TestHelpers.php index 2aa44ea..60f00bc 100644 --- a/tests/Integration/TestHelpers.php +++ b/tests/Integration/TestHelpers.php @@ -26,7 +26,7 @@ class TestHelpers public const PHP_FILES_CACHE_ADAPTER_DIR = __DIR__ . '/../var/phpFiles.cache'; - public const LOG_LEVEL = Logger::WARNING; // set to Logger::DEBUG to get high verbosity + public const LOG_LEVEL = Logger::DEBUG; // set to Logger::DEBUG to get high verbosity public static function createLogger(): Logger { diff --git a/tests/Integration/WatcherClient.php b/tests/Integration/WatcherClient.php index 29e51a9..a7d407b 100644 --- a/tests/Integration/WatcherClient.php +++ b/tests/Integration/WatcherClient.php @@ -4,45 +4,27 @@ namespace CrowdSecBouncer\Tests\Integration; -use CrowdSecBouncer\Constants; -use CrowdSecBouncer\RestClient\FileGetContents; -use CrowdSecBouncer\RestClient\Curl; -use Psr\Log\LoggerInterface; +use CrowdSec\LapiClient\AbstractClient; +use CrowdSec\LapiClient\ClientException; +use CrowdSec\LapiClient\Constants; -class WatcherClient +class WatcherClient extends AbstractClient { - public const WATCHER_LOGIN = 'PhpUnitTestMachine'; - public const WATCHER_PASSWORD = 'PhpUnitTestMachinePassword'; + public const WATCHER_LOGIN_ENDPOINT = '/v1/watchers/login'; - public const HOURS24 = '+24 hours'; - - /** @var LoggerInterface */ - private $logger; + public const WATCHER_DECISIONS_ENDPOINT = '/v1/decisions'; - /** @var RestClient */ - private $watcherClient; + public const WATCHER_ALERT_ENDPOINT = '/v1/alerts'; - /** @var array */ - private $baseHeaders; + public const HOURS24 = '+24 hours'; /** @var string */ private $token; - private $configs; - - public function __construct(array $configs, LoggerInterface $logger) + public function __construct(array $configs) { - $this->logger = $logger; $this->configs = $configs; - $this->baseHeaders = [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - 'User-Agent' => Constants::BASE_USER_AGENT, - ]; - $this->configs['headers'] = $this->baseHeaders; - $apiUrl = getenv('LAPI_URL'); - $this->configs['api_url'] = $apiUrl; - $this->configs['api_timeout'] = 2; + $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; $agentTlsPath = getenv('AGENT_TLS_PATH'); if (!$agentTlsPath) { throw new \Exception('Using TLS auth for agent is required. Please set AGENT_TLS_PATH env.'); @@ -52,18 +34,32 @@ public function __construct(array $configs, LoggerInterface $logger) $this->configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; $this->configs['tls_verify_peer'] = false; - $useCurl = !empty($this->configs['use_curl']); - $this->watcherClient = $useCurl ? new Curl($this->configs, $this->logger) : new FileGetContents( - $this->configs, - $this->logger - ); - $this->logger->info('', ['message' => 'Watcher client initialized', 'use_curl' => $useCurl]); + parent::__construct($this->configs); + } + + /** + * Make a request. + * + * @throws ClientException + */ + private function manageRequest( + string $method, + string $endpoint, + array $parameters = [] + ): array { + $this->logger->debug('', [ + 'type' => 'WATCHER_CLIENT_REQUEST', + 'method' => $method, + 'endpoint' => $endpoint, + 'parameters' => $parameters, + ]); + + return $this->request($method, $endpoint, $parameters, $this->headers); } /** Set the initial watcher state */ public function setInitialState(): void { - $this->logger->info('', ['message' => 'Set initial state']); $this->deleteAllDecisions(); $now = new \DateTime(); $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, 'captcha'); @@ -97,47 +93,43 @@ private function ensureLogin(): void { if (!$this->token) { $data = [ - 'machine_id' => self::WATCHER_LOGIN, - 'password' => self::WATCHER_PASSWORD, + 'scenarios' => [], ]; - /** @var array */ - $credentials = $this->watcherClient->request('/v1/watchers/login', null, $data, 'POST'); + $credentials = $this->manageRequest( + 'POST', + self::WATCHER_LOGIN_ENDPOINT, + $data + ); + $this->token = $credentials['token']; - $this->baseHeaders['Authorization'] = 'Bearer ' . $this->token; + $this->headers['Authorization'] = 'Bearer ' . $this->token; } } - /** - * Request the Watcher API. - */ - private function request( - string $endpoint, - array $queryParams = null, - array $bodyParams = null, - string $method = 'GET', - array $headers = null - ): ?array { - $this->ensureLogin(); - - return $this->watcherClient->request( - $endpoint, - $queryParams, - $bodyParams, - $method, - $headers ?: $this->baseHeaders - ); - } - public function deleteAllDecisions(): void { // Delete all existing decisions. - $this->logger->info('', ['message' => 'Delete all decisions']); - $this->request('/v1/decisions', null, null, 'DELETE'); + $this->ensureLogin(); + + $this->manageRequest( + 'DELETE', + self::WATCHER_DECISIONS_ENDPOINT, + [] + ); } protected function getFinalScope($scope, $value) { - return (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : $scope; + + $scope = (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : + $scope; + /** + * Must use capital first letter as the crowdsec agent seems to query with first capital letter + * during getStreamDecisions + * @see https://github.com/crowdsecurity/crowdsec/blob/ae6bf3949578a5f3aa8ec415e452f15b404ba5af/pkg/database/decisions.go#L56 + */ + return ucfirst($scope); + } public function addDecision( @@ -181,8 +173,11 @@ public function addDecision( 'start_at' => $startAt, 'stop_at' => $stopAt, ]; - $result = $this->request('/v1/alerts', null, [$body], 'POST'); - $this->logger->info('', ['message' => 'Decision ' . $result[0] . ' added: ' . - $body['decisions'][0]['scenario'] . '']); + + $result = $this->manageRequest( + 'POST', + self::WATCHER_ALERT_ENDPOINT, + [$body] + ); } } diff --git a/tests/end-to-end/__tests__/3-stream-mode.js b/tests/end-to-end/__tests__/3-stream-mode.js index 5fdbc2f..d12775d 100644 --- a/tests/end-to-end/__tests__/3-stream-mode.js +++ b/tests/end-to-end/__tests__/3-stream-mode.js @@ -37,7 +37,7 @@ describe(`Stream mode run`, () => { }); it("Should display the homepage with no remediation", async () => { - await runCacheAction("warm-up"); + await runCacheAction("clear"); await publicHomepageShouldBeAccessible(); }); diff --git a/tests/end-to-end/settings/base.php.dist b/tests/end-to-end/settings/base.php.dist index b5ef524..450d755 100644 --- a/tests/end-to-end/settings/base.php.dist +++ b/tests/end-to-end/settings/base.php.dist @@ -7,8 +7,7 @@ $crowdSecStandaloneBouncerConfig = [ 'auth_type' => 'api_key', 'api_url' => 'https://crowdsec:8080', 'api_key' => 'REPLACE_API_KEY', - 'api_timeout'=> 1, - 'api_user_agent'=> 'CrowdSec PHP Library/test', + 'api_timeout' => 1, 'use_curl' => false, 'tls_cert_path' => '', 'tls_key_path' => '', @@ -17,8 +16,8 @@ $crowdSecStandaloneBouncerConfig = [ // Debug/Test 'debug_mode' => true, 'display_errors' => true, - 'log_directory_path' => __DIR__.'/.logs', - 'fs_cache_path' => __DIR__.'/.cache', + 'log_directory_path' => __DIR__ . '/.logs', + 'fs_cache_path' => __DIR__ . '/.cache', 'forced_test_ip' => 'REPLACE_FORCED_IP', 'forced_test_forwarded_ip' => 'REPLACE_FORCED_FORWARDED_IP', // Bouncer @@ -26,7 +25,6 @@ $crowdSecStandaloneBouncerConfig = [ 'stream_mode' => false, 'excluded_uris' => ['/favicon.ico'], 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, - 'max_remediation_level' => Constants::REMEDIATION_BAN, 'trust_ip_forward_array' => ['REPLACE_PROXY_IP'], // Cache 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, @@ -34,11 +32,10 @@ $crowdSecStandaloneBouncerConfig = [ 'memcached_dsn' => 'memcached://memcached:11211', 'clean_ip_cache_duration' => 1, 'bad_ip_cache_duration' => 1, - 'captcha_cache_duration'=> 86400, - 'geolocation_cache_duration'=> 86400, + 'captcha_cache_duration' => 86400, // Geolocation 'geolocation' => [ - 'save_result' => true, + 'cache_duration' => 86400, 'enabled' => false, 'type' => 'maxmind', 'maxmind' => [ @@ -46,30 +43,42 @@ $crowdSecStandaloneBouncerConfig = [ 'database_path' => '/var/www/html/my-own-modules/crowdsec-php-lib/tests/GeoLite2-Country.mmdb' ] ], - // Walls + // Settings for ban and captcha walls + 'custom_css' => '', + // true to hide CrowdSec mentions on ban and captcha walls. 'hide_mentions' => false, - - 'theme_color_text_primary' => 'black', - 'theme_color_text_secondary' => '#AAA', - 'theme_color_text_button' => 'white', - 'theme_color_text_error_message' => '#b90000', - 'theme_color_background_page' => '#eee', - 'theme_color_background_container' => 'white', - 'theme_color_background_button' => '#626365', - 'theme_color_background_button_hover' => '#333', - - 'theme_text_captcha_wall_tab_title' => 'Oops..', - 'theme_text_captcha_wall_title' => 'Hmm, sorry but...', - 'theme_text_captcha_wall_subtitle' => 'Please complete the security check.', - 'theme_text_captcha_wall_refresh_image_link' => 'refresh image', - 'theme_text_captcha_wall_captcha_placeholder' => 'Type here...', - 'theme_text_captcha_wall_send_button' => 'CONTINUE', - 'theme_text_captcha_wall_error_message' => 'Please try again.', - 'theme_text_captcha_wall_footer' => '', - - 'theme_text_ban_wall_tab_title' => 'Oops..', - 'theme_text_ban_wall_title' => '🤭 Oh!', - 'theme_text_ban_wall_subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', - 'theme_text_ban_wall_footer' => '', - 'theme_custom_css' => '', + 'color' => [ + 'text' => [ + 'primary' => 'black', + 'secondary' => '#AAA', + 'button' => 'white', + 'error_message' => '#b90000', + ], + 'background' => [ + 'page' => '#eee', + 'container' => 'white', + 'button' => '#626365', + 'button_hover' => '#333', + ], + ], + 'text' => [ + // Settings for captcha wall + 'captcha_wall' => [ + 'tab_title' => 'Oops..', + 'title' => 'Hmm, sorry but...', + 'subtitle' => 'Please complete the security check.', + 'refresh_image_link' => 'refresh image', + 'captcha_placeholder' => 'Type here...', + 'send_button' => 'CONTINUE', + 'error_message' => 'Please try again.', + 'footer' => '', + ], + // Settings for ban wall + 'ban_wall' => [ + 'tab_title' => 'Oops..', + 'title' => '🤭 Oh!', + 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', + 'footer' => '', + ], + ], ]; diff --git a/tests/end-to-end/utils/helpers.js b/tests/end-to-end/utils/helpers.js index 33e7935..58b0bc4 100644 --- a/tests/end-to-end/utils/helpers.js +++ b/tests/end-to-end/utils/helpers.js @@ -24,7 +24,7 @@ const runCacheAction = async (actionType = "refresh") => { const runGeolocationTest = async (ip, saveResult, brokenDb = false) => { let url = `/my-own-modules/crowdsec-php-lib/scripts/public/geolocation-test.php?ip=${ip}`; if (saveResult) { - url += "&save-result=1"; + url += "&cache-duration=120"; } if (brokenDb) { url += "&broken-db=1"; diff --git a/tests/end-to-end/utils/watcherClient.js b/tests/end-to-end/utils/watcherClient.js index 4d22f11..7ad9c0c 100755 --- a/tests/end-to-end/utils/watcherClient.js +++ b/tests/end-to-end/utils/watcherClient.js @@ -139,7 +139,6 @@ module.exports.addDecision = async ( if (["Ip", "Range"].includes(scope)) { // IPv6 if (value.includes(":")) { - // @TODO Handle IP range for Ipv6 finalScope = "Ip"; } else { let startIp; diff --git a/tools/coding-standards/php-cs-fixer/.php-cs-fixer.dist.php b/tools/coding-standards/php-cs-fixer/.php-cs-fixer.dist.php index fbbf48a..f3e8f14 100644 --- a/tools/coding-standards/php-cs-fixer/.php-cs-fixer.dist.php +++ b/tools/coding-standards/php-cs-fixer/.php-cs-fixer.dist.php @@ -23,6 +23,6 @@ PhpCsFixer\Finder::create() ->in(__DIR__ . '/../../../src')->exclude(['templates']) ->in(__DIR__ . '/../../../tests/Integration')->depth(1) - ->in(__DIR__ . '/../../../scripts') + ->in(__DIR__ . '/../../../scripts')->exclude(['public']) ) ; \ No newline at end of file From ccfada7a051d750489f66d0f8e007e08fbe792b5 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Thu, 12 Jan 2023 15:05:32 +0900 Subject: [PATCH 02/10] style(*): Pass through coding standards --- composer.json | 3 +- src/AbstractBouncer.php | 242 +++++++++-------------- src/Configuration.php | 7 +- src/Constants.php | 16 +- src/StandaloneBouncer.php | 24 ++- src/Template.php | 3 + tests/Integration/GeolocationTest.php | 2 - tests/Integration/IpVerificationTest.php | 2 - 8 files changed, 124 insertions(+), 175 deletions(-) diff --git a/composer.json b/composer.json index a100132..76a80c9 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ "twig/twig": "^3.4.2", "gregwar/captcha": "^1.1", "mlocati/ip-lib": "^1.18", - "ext-json": "*" + "ext-json": "*", + "ext-gd": "*" }, "require-dev": { "phpunit/phpunit": "^8.5.30 || ^9.3" diff --git a/src/AbstractBouncer.php b/src/AbstractBouncer.php index ad8b52f..2979a61 100644 --- a/src/AbstractBouncer.php +++ b/src/AbstractBouncer.php @@ -9,16 +9,17 @@ use CrowdSec\LapiClient\RequestHandler\FileGetContents; use CrowdSec\RemediationEngine\CacheStorage\AbstractCache; use CrowdSec\RemediationEngine\AbstractRemediation; +use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; use CrowdSec\RemediationEngine\CacheStorage\Memcached; use CrowdSec\RemediationEngine\CacheStorage\PhpFiles; use CrowdSec\RemediationEngine\CacheStorage\Redis; use CrowdSecBouncer\Fixes\Gregwar\Captcha\CaptchaBuilder; use Gregwar\Captcha\PhraseBuilder; use IPLib\Factory; -use Monolog\Formatter\LineFormatter; use Monolog\Handler\NullHandler; -use Monolog\Handler\RotatingFileHandler; use Monolog\Logger; +use Psr\Cache\CacheException; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Config\Definition\Processor; @@ -44,8 +45,8 @@ abstract class AbstractBouncer implements BouncerInterface public function __construct( array $configs, AbstractRemediation $remediationEngine, - LoggerInterface $logger = null) - { + LoggerInterface $logger = null + ) { if (!$logger) { $logger = new Logger('null'); $logger->pushHandler(new NullHandler()); @@ -71,7 +72,7 @@ public function __construct( * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" * representing the image data */ - public static function buildCaptchaCouple(): array + private static function buildCaptchaCouple(): array { $captchaBuilder = new CaptchaBuilder(); @@ -113,21 +114,6 @@ public function getConfigs(): array return $this->configs; } - /** - * Return cached variables associated to an IP. - * - * @param string $cacheTag - * @param array $names - * @param string $ip - * @return array - */ - public function getIpVariables(string $prefix, array $names, string $ip): array - { - $cache = $this->getCache(); - - return $cache->getIpVariables($prefix, $names, $ip); - } - /** * Returns the logger instance. * @@ -151,7 +137,7 @@ public function getRemediationEngine(): AbstractRemediation * @param string $ip The IP to check * * @return string the remediation to apply (ex: 'ban', 'captcha', 'bypass') - * + * @throws BouncerException */ public function getRemediationForIp(string $ip): string { @@ -162,6 +148,7 @@ public function getRemediationForIp(string $ip): string * This method prune the cache: it removes all the expired cache items. * * @return bool If the cache has been successfully pruned or not + * @throws CacheStorageException */ public function pruneCache(): bool { @@ -180,45 +167,21 @@ public function refreshBlocklistCache(): array return $this->getRemediationEngine()->refreshDecisions(); } - /** - * Set a ip variable. - * - * @param string $cacheScope - * @param array $pairs - * @param string $ip - * @return void - */ - public function setIpVariables(string $cacheScope, array $pairs, string $ip, int $duration, string $cacheTag = - ''): void - { - $cache = $this->getCache(); - $cache->setIpVariables($cacheScope, $pairs, $ip, $duration, $cacheTag); - } - - /** - * Unset ip variables. - * - * @param string $cacheTag - * @param array $names - * @param string $ip - * @return void - */ - public function unsetIpVariables(string $cacheScope, array $names, string $ip, int $duration, string $cacheTag = ''): void - { - $cache = $this->getCache(); - $cache->unsetIpVariables($cacheScope, $names, $ip, $duration, $cacheTag); - } - /** * Bounce process * * @return void + * @throws BouncerException + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ protected function bounceCurrentIp(): void { // Retrieve the current IP (even if it is a proxy IP) or a testing IP - $forcedTestIp = $this->getStringConfig('forced_test_ip'); - $ip = !empty($forcedTestIp) ? $forcedTestIp : $this->getRemoteIp(); + $forcedTestIp = $this->getConfig('forced_test_ip'); + $ip = $forcedTestIp ?: $this->getRemoteIp(); $ip = $this->handleForwardedFor($ip, $this->configs); $remediation = $this->getRemediationForIp($ip); $this->handleRemediation($remediation, $ip); @@ -226,7 +189,9 @@ protected function bounceCurrentIp(): void /** * Check if the captcha filled by the user is correct or not. - * We are permissive with the user (0 is interpreted as "o" and 1 in interpreted as "l"). + * We are permissive with the user: + * - case is not sensitive + * - (0 is interpreted as "o" and 1 in interpreted as "l"). * * @param string $expected The expected phrase * @param string $try The phrase to check (the user input) @@ -236,7 +201,7 @@ protected function bounceCurrentIp(): void * * @SuppressWarnings(PHPMD.StaticAccess) */ - protected function checkCaptcha(string $expected, string $try, string $ip): bool + private function checkCaptcha(string $expected, string $try, string $ip): bool { $solved = PhraseBuilder::comparePhrases($expected, $try); $this->logger->info('Captcha has been solved', [ @@ -249,12 +214,14 @@ protected function checkCaptcha(string $expected, string $try, string $ip): bool } /** - * - * @SuppressWarnings(PHPMD.StaticAccess) + * @param string $ip + * @return void + * @throws CacheStorageException + * @throws InvalidArgumentException */ - protected function displayCaptchaWall(string $ip): void + private function displayCaptchaWall(string $ip): void { - $captchaVariables = $this->getIpVariables( + $captchaVariables = $this->getCache()->getIpVariables( Constants::CACHE_TAG_CAPTCHA, ['crowdsec_captcha_resolution_failed', 'crowdsec_captcha_inline_image'], $ip @@ -274,23 +241,13 @@ protected function displayCaptchaWall(string $ip): void * * @return string The HTML compiled template */ - protected function getAccessForbiddenHtmlTemplate(): string + private function getAccessForbiddenHtmlTemplate(): string { $template = new Template('ban.html.twig'); return $template->render($this->configs); } - protected function getArrayConfig(string $name): array - { - return !empty($this->configs[$name]) ? (array)$this->configs[$name] : []; - } - - protected function getBoolConfig(string $name): bool - { - return !empty($this->configs[$name]); - } - /** * Returns a default "CrowdSec Captcha (401)" HTML template. * @@ -299,7 +256,7 @@ protected function getBoolConfig(string $name): bool * @param string $captchaResolutionFormUrl * @return string */ - protected function getCaptchaHtmlTemplate( + private function getCaptchaHtmlTemplate( bool $error, string $captchaImageSrc, string $captchaResolutionFormUrl @@ -316,47 +273,40 @@ protected function getCaptchaHtmlTemplate( )); } - protected function getIntegerConfig(string $name): int - { - return !empty($this->configs[$name]) ? (int)$this->configs[$name] : 0; - } - - protected function getStringConfig(string $name): string - { - return !empty($this->configs[$name]) ? (string)$this->configs[$name] : ''; - } - /** * @return array [[string, string], ...] Returns IP ranges to trust as proxies as an array of comparables ip bounds */ - protected function getTrustForwardedIpBoundsList(): array + private function getTrustForwardedIpBoundsList(): array { - return $this->getArrayConfig('trust_ip_forward_array'); + return $this->getConfig('trust_ip_forward_array') ?? []; } /** * @return void * - * @SuppressWarnings(PHPMD.StaticAccess) */ - protected function handleBanRemediation(): void + private function handleBanRemediation(): void { $body = $this->getAccessForbiddenHtmlTemplate(); $this->sendResponse($body, 403); } - protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache{ - + /** + * @throws BouncerException + * @throws CacheStorageException + */ + protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache + { $cacheSystem = $configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; switch ($cacheSystem) { case Constants::CACHE_SYSTEM_PHPFS: - $cache = new PhpFiles($configs, $logger); + $cache = new PhpFiles($configs, $logger); break; case Constants::CACHE_SYSTEM_MEMCACHED: - $cache = new Memcached($configs, $logger); + $cache = new Memcached($configs, $logger); break; case Constants::CACHE_SYSTEM_REDIS: - $cache = new Redis($configs, $logger); + $cache = new Redis($configs, $logger); break; default: throw new BouncerException("Unknown selected cache technology: $cacheSystem"); @@ -370,13 +320,16 @@ protected function handleCache(array $configs, LoggerInterface $logger): Abstrac * * @return void * - * @SuppressWarnings(PHPMD.StaticAccess) + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ - protected function handleCaptchaRemediation(string $ip) + private function handleCaptchaRemediation(string $ip) { // Check captcha resolution form $this->handleCaptchaResolutionForm($ip); - $cachedCaptchaVariables = $this->getIpVariables( + $cachedCaptchaVariables = $this->getCache()->getIpVariables( Constants::CACHE_TAG_CAPTCHA, ['crowdsec_captcha_has_to_be_resolved'], $ip @@ -395,9 +348,13 @@ protected function handleCaptchaRemediation(string $ip) !empty($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '/', ]; - $duration = $this->getIntegerConfig('captcha_cache_duration'); - $this->setIpVariables( - Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; + $this->getCache()->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, + $captchaVariables, + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA ); } @@ -411,11 +368,15 @@ protected function handleCaptchaRemediation(string $ip) * @param string $ip * @return void * + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException * @SuppressWarnings(PHPMD.ElseExpression) */ - protected function handleCaptchaResolutionForm(string $ip): void + private function handleCaptchaResolutionForm(string $ip): void { - $cachedCaptchaVariables = $this->getIpVariables( + $cachedCaptchaVariables = $this->getCache()->getIpVariables( Constants::CACHE_TAG_CAPTCHA, [ 'crowdsec_captcha_has_to_be_resolved', @@ -433,7 +394,7 @@ protected function handleCaptchaResolutionForm(string $ip): void null !== $this->getPostedVariable('phrase') && null !== $cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'] ) { - $duration = $this->getIntegerConfig('captcha_cache_duration'); + $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; if ( $this->checkCaptcha( (string)$cachedCaptchaVariables['crowdsec_captcha_phrase_to_guess'], @@ -442,7 +403,7 @@ protected function handleCaptchaResolutionForm(string $ip): void ) ) { // User has correctly filled the captcha - $this->setIpVariables( + $this->getCache()->setIpVariables( Constants::CACHE_TAG_CAPTCHA, ['crowdsec_captcha_has_to_be_resolved' => false], $ip, @@ -455,15 +416,19 @@ protected function handleCaptchaResolutionForm(string $ip): void 'crowdsec_captcha_resolution_failed', 'crowdsec_captcha_resolution_redirect', ]; - $this->unsetIpVariables( - Constants::CACHE_TAG_CAPTCHA, $unsetVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + $this->getCache()->unsetIpVariables( + Constants::CACHE_TAG_CAPTCHA, + $unsetVariables, + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA ); $redirect = $cachedCaptchaVariables['crowdsec_captcha_resolution_redirect'] ?? '/'; header("Location: $redirect"); exit(0); } else { // The user failed to resolve the captcha. - $this->setIpVariables( + $this->getCache()->setIpVariables( Constants::CACHE_TAG_CAPTCHA, ['crowdsec_captcha_resolution_failed' => true], $ip, @@ -474,7 +439,7 @@ protected function handleCaptchaResolutionForm(string $ip): void } } - protected function handleClient(array $configs, LoggerInterface $logger) + protected function handleClient(array $configs, LoggerInterface $logger): BouncerClient { $requestHandler = empty($configs['use_curl']) ? new FileGetContents($configs) : new Curl($configs); @@ -490,7 +455,7 @@ protected function handleClient(array $configs, LoggerInterface $logger) * * @SuppressWarnings(PHPMD.ElseExpression) */ - protected function handleForwardedFor(string $ip, array $configs): string + private function handleForwardedFor(string $ip, array $configs): string { $forwardedIp = null; if (empty($configs['forced_test_forwarded_ip'])) { @@ -505,7 +470,7 @@ protected function handleForwardedFor(string $ip, array $configs): string 'original_ip' => $ip, ]); } else { - $forwardedIp = (string) $configs['forced_test_forwarded_ip']; + $forwardedIp = (string)$configs['forced_test_forwarded_ip']; } if (is_string($forwardedIp) && $this->shouldTrustXforwardedFor($ip)) { @@ -517,6 +482,7 @@ protected function handleForwardedFor(string $ip, array $configs): string 'x_forwarded_for_ip' => is_string($forwardedIp) ? $forwardedIp : 'type not as expected', ]); } + return $ip; } @@ -526,8 +492,12 @@ protected function handleForwardedFor(string $ip, array $configs): string * @param string $remediation * @param string $ip * @return void + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ - protected function handleRemediation(string $remediation, string $ip) + private function handleRemediation(string $remediation, string $ip) { switch ($remediation) { case Constants::REMEDIATION_CAPTCHA: @@ -541,34 +511,7 @@ protected function handleRemediation(string $remediation, string $ip) } } - /** - * @param array $configs - * @param string $loggerName - * @return void - */ - protected function initFileLogger(array $configs, string $loggerName): LoggerInterface - { - $logger = new Logger($loggerName); - $logDir = $configs['log_directory_path'] ?? __DIR__ . '/.logs'; - if (empty($configs['disable_prod_log'])) { - $logPath = $logDir . '/prod.log'; - $fileHandler = new RotatingFileHandler($logPath, 0, Logger::INFO); - $fileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%message%|%context%\n")); - $logger->pushHandler($fileHandler); - } - - // Set custom readable logger when debug=true - if (!empty($configs['debug_mode'])) { - $debugLogPath = $logDir . '/debug.log'; - $debugFileHandler = new RotatingFileHandler($debugLogPath, 0, Logger::DEBUG); - $debugFileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%message%|%context%\n")); - $logger->pushHandler($debugFileHandler); - } - - return $logger; - } - - protected function shouldTrustXforwardedFor(string $ip): bool + private function shouldTrustXforwardedFor(string $ip): bool { $parsedAddress = Factory::parseAddressString($ip, 3); if (null === $parsedAddress) { @@ -591,17 +534,18 @@ protected function shouldTrustXforwardedFor(string $ip): bool } /** - * Cap the remediation to a fixed value given in configuration. + * Cap the remediation to a fixed value given by the bouncing level configuration. * - * @param string $remediation The maximum remediation that can ban applied (ex: 'ban', 'captcha', 'bypass') + * @param string $remediation (ex: 'ban', 'captcha', 'bypass') * * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') + * @throws BouncerException */ private function capRemediationLevel(string $remediation): string { - $orderedRemediations = $this->getRemediationEngine()->getConfig('ordered_remediations')??[]; + $orderedRemediations = $this->getRemediationEngine()->getConfig('ordered_remediations') ?? []; - $bouncingLevel = $this->getStringConfig('bouncing_level')??Constants::BOUNCING_LEVEL_NORMAL; + $bouncingLevel = $this->getConfig('bouncing_level') ?? Constants::BOUNCING_LEVEL_NORMAL; // Compute max remediation level switch ($bouncingLevel) { case Constants::BOUNCING_LEVEL_DISABLED: @@ -617,9 +561,8 @@ private function capRemediationLevel(string $remediation): string throw new BouncerException("Unknown $bouncingLevel"); } - - $currentIndex = (int) array_search($remediation, $orderedRemediations); - $maxIndex = (int) array_search( + $currentIndex = (int)array_search($remediation, $orderedRemediations); + $maxIndex = (int)array_search( $maxRemediationLevel, $orderedRemediations ); @@ -654,7 +597,10 @@ private function getCache(): AbstractCache * @param string $ip * @return bool * - * @SuppressWarnings(PHPMD.StaticAccess) + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): bool { @@ -668,15 +614,19 @@ private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): b } elseif (null !== $this->getPostedVariable('refresh') && (int)$this->getPostedVariable('refresh')) { // Handle image refresh. // Generate new captcha image for the user - $captchaCouple = Bouncer::buildCaptchaCouple(); + $captchaCouple = $this->buildCaptchaCouple(); $captchaVariables = [ 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], 'crowdsec_captcha_resolution_failed' => false, ]; - $duration = $this->getIntegerConfig('captcha_cache_duration'); - $this->setIpVariables( - Constants::CACHE_TAG_CAPTCHA, $captchaVariables, $ip, $duration, Constants::CACHE_TAG_CAPTCHA + $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; + $this->getCache()->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, + $captchaVariables, + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA ); $result = true; diff --git a/src/Configuration.php b/src/Configuration.php index 5b4e9d7..2a65c5e 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -5,7 +5,6 @@ namespace CrowdSecBouncer; use InvalidArgumentException; -use RuntimeException; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -23,7 +22,6 @@ */ class Configuration implements ConfigurationInterface { - /** * @var string[] */ @@ -58,6 +56,7 @@ public function cleanConfigs(array $configs): array /** * {@inheritdoc} + * @throws InvalidArgumentException */ public function getConfigTreeBuilder(): TreeBuilder { @@ -75,7 +74,6 @@ public function getConfigTreeBuilder(): TreeBuilder private function addTemplateNodes($rootNode) { - // @TODO update docs for config $defaultSubtitle = 'This page is protected against cyber attacks and your IP has been banned by our system.'; $rootNode->children() ->arrayNode('color')->addDefaultsIfNotSet() @@ -132,6 +130,7 @@ private function addTemplateNodes($rootNode) * * @param NodeDefinition|ArrayNodeDefinition $rootNode * @return void + * @throws InvalidArgumentException */ private function addBouncerNodes($rootNode) { @@ -162,6 +161,7 @@ private function addBouncerNodes($rootNode) * * @param NodeDefinition|ArrayNodeDefinition $rootNode * @return void + * @throws InvalidArgumentException */ private function addCacheNodes($rootNode) { @@ -212,5 +212,4 @@ private function addDebugNodes($rootNode) ->booleanNode('display_errors')->defaultValue(false)->end() ->end(); } - } diff --git a/src/Constants.php b/src/Constants.php index ddd2d11..833b36e 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -22,8 +22,6 @@ class Constants public const AUTH_KEY = 'api_key'; /** @var string The TLS auth type */ public const AUTH_TLS = 'tls'; - /** @var string The user agent used to send request to LAPI */ - public const BASE_USER_AGENT = 'PHP CrowdSec Bouncer/' . self::VERSION; /** @var string The "disabled" bouncing level */ public const BOUNCING_LEVEL_DISABLED = 'bouncing_disabled'; /** @var string The "flex" bouncing level */ @@ -46,20 +44,12 @@ class Constants public const CACHE_SYSTEM_REDIS = 'redis'; /** @var string Cache tag for captcha flow */ public const CACHE_TAG_CAPTCHA = 'captcha'; - /** @var string Cache tag for geolocation */ - public const CACHE_TAG_GEO = 'geolocation'; - /** @var string Cache tag for remediation */ - public const CACHE_TAG_REM = 'remediation'; /** @var string The Default URL of the CrowdSec LAPI */ public const DEFAULT_LAPI_URL = 'http://localhost:8080'; /** @var string The "MaxMind" geolocation type */ public const GEOLOCATION_TYPE_MAXMIND = 'maxmind'; - /** @var string The Maxmind "City" database type */ - public const MAXMIND_CITY = 'city'; /** @var string The Maxmind "Country" database type */ public const MAXMIND_COUNTRY = 'country'; - /** @var array The list of each known remediation, sorted by priority */ - public const ORDERED_REMEDIATIONS = [self::REMEDIATION_BAN, self::REMEDIATION_CAPTCHA, self::REMEDIATION_BYPASS]; /** @var string The ban remediation */ public const REMEDIATION_BAN = 'ban'; /** @var string The bypass remediation */ @@ -67,11 +57,11 @@ class Constants /** @var string The captcha remediation */ public const REMEDIATION_CAPTCHA = 'captcha'; /** @var string The CrowdSec country scope for decisions */ - public const SCOPE_COUNTRY = 'Country'; + public const SCOPE_COUNTRY = 'country'; /** @var string The CrowdSec Ip scope for decisions */ - public const SCOPE_IP = 'Ip'; + public const SCOPE_IP = 'ip'; /** @var string The CrowdSec Range scope for decisions */ - public const SCOPE_RANGE = 'Range'; + public const SCOPE_RANGE = 'range'; /** @var string Path for html templates folder (e.g. ban and captcha wall) */ public const TEMPLATES_DIR = __DIR__ . "/templates"; /** @var string The last version of this library */ diff --git a/src/StandaloneBouncer.php b/src/StandaloneBouncer.php index 891fe80..edebce0 100644 --- a/src/StandaloneBouncer.php +++ b/src/StandaloneBouncer.php @@ -4,10 +4,13 @@ namespace CrowdSecBouncer; +use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; use Exception; use IPLib\Factory; use CrowdSec\RemediationEngine\LapiRemediation; use CrowdSec\RemediationEngine\Logger\FileLog; +use Psr\Cache\CacheException; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; /** @@ -22,10 +25,13 @@ */ class StandaloneBouncer extends AbstractBouncer { - + /** + * @throws BouncerException + * @throws CacheStorageException + */ public function __construct(array $configs, LoggerInterface $logger = null) { - $this->logger = $logger ?:new FileLog($configs, 'php_standalone_bouncer'); + $this->logger = $logger ?: new FileLog($configs, 'php_standalone_bouncer'); $configs = $this->handleTrustedIpsConfig($configs); $configs['user_agent_version'] = Constants::VERSION; $configs['user_agent_suffix'] = 'Standalone'; @@ -80,8 +86,12 @@ public function getRemoteIp(): string /** * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. * - * @param array $configs * @return bool + * @throws BouncerException + * @throws CacheException + * @throws CacheStorageException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ public function safelyBounce(): bool { @@ -102,7 +112,7 @@ public function safelyBounce(): bool 'file' => $e->getFile(), 'line' => $e->getLine(), ]); - if (!empty($this->getBoolConfig('display_errors'))) { + if (true === $this->getConfig('display_errors')) { throw $e; } } @@ -113,6 +123,7 @@ public function safelyBounce(): bool /** * Send HTTP response. + * @throws BouncerException */ public function sendResponse(?string $body, int $statusCode = 200): void { @@ -146,7 +157,7 @@ public function sendResponse(?string $body, int $statusCode = 200): void */ public function shouldBounceCurrentIp(): bool { - $excludedURIs = $this->getArrayConfig('excluded_uris'); + $excludedURIs = $this->getConfig('excluded_uris') ?? []; if (isset($_SERVER['REQUEST_URI']) && \in_array($_SERVER['REQUEST_URI'], $excludedURIs)) { $this->logger->debug('Will not bounce as URI is excluded', [ 'type' => 'SHOULD_NOT_BOUNCE', @@ -155,7 +166,7 @@ public function shouldBounceCurrentIp(): bool return false; } - $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getStringConfig('bouncing_level')); + $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getConfig('bouncing_level')); if ($bouncingDisabled) { $this->logger->debug('Will not bounce as bouncing is disabled', [ 'type' => 'SHOULD_NOT_BOUNCE', @@ -168,7 +179,6 @@ public function shouldBounceCurrentIp(): bool return true; } - /** * Initialize the bouncer. * diff --git a/src/Template.php b/src/Template.php index 9a6612f..154d22d 100644 --- a/src/Template.php +++ b/src/Template.php @@ -30,6 +30,9 @@ class Template * @param string $path * @param string $templatesDir * @param array $options + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError */ public function __construct(string $path, string $templatesDir = Constants::TEMPLATES_DIR, array $options = []) { diff --git a/tests/Integration/GeolocationTest.php b/tests/Integration/GeolocationTest.php index c4670de..88d2abb 100644 --- a/tests/Integration/GeolocationTest.php +++ b/tests/Integration/GeolocationTest.php @@ -93,7 +93,6 @@ public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindCon 'api_url' => TestHelpers::getLapiUrl(), 'geolocation' => $geolocationConfig, 'use_curl' => $this->useCurl, - 'api_user_agent' => 'Unit test/' . Constants::BASE_USER_AGENT, 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR, 'stream_mode' => false @@ -168,7 +167,6 @@ public function testCanVerifyIpAndCountryWithMaxmindInStreamMode(array $maxmindC 'stream_mode' => true, 'geolocation' => $geolocationConfig, 'use_curl' => $this->useCurl, - 'api_user_agent' => 'Unit test/' . Constants::BASE_USER_AGENT, 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR ]; diff --git a/tests/Integration/IpVerificationTest.php b/tests/Integration/IpVerificationTest.php index d3ec1e9..deaa18e 100644 --- a/tests/Integration/IpVerificationTest.php +++ b/tests/Integration/IpVerificationTest.php @@ -227,7 +227,6 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o 'auth_type' => $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, 'api_key' => TestHelpers::getBouncerKey(), 'api_url' => TestHelpers::getLapiUrl(), - 'api_user_agent' => TestHelpers::UNIT_TEST_AGENT_PREFIX . '/' . Constants::BASE_USER_AGENT, 'stream_mode' => true, 'use_curl' => $this->useCurl, 'cache_system' => $cacheAdapterName, @@ -305,7 +304,6 @@ public function testCanVerifyIpInStreamModeWithCacheSystem($cacheAdapterName, $o 'api_url' => TestHelpers::getLapiUrl(), 'stream_mode' => true, 'use_curl' => $this->useCurl, - 'api_user_agent' => TestHelpers::UNIT_TEST_AGENT_PREFIX . '/' . Constants::BASE_USER_AGENT, 'cache_system' => $cacheAdapterName, 'redis_dsn' => getenv('REDIS_DSN'), 'memcached_dsn' => getenv('MEMCACHED_DSN'), From 3d89fc516af4afaf3fde65134ede732b7230cb82 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Tue, 17 Jan 2023 15:11:55 +0900 Subject: [PATCH 03/10] test(unit): Add unit test --- .github/workflows/coding-standards.yml | 29 + .github/workflows/test-suite.yml | 17 +- .gitignore | 2 +- CHANGELOG.md | 12 + composer.json | 5 +- docs/DEVELOPER.md | 196 +----- docs/INSTALLATION_GUIDE.md | 72 +-- docs/USER_GUIDE.md | 391 +++++++----- scripts/auto-prepend/settings.example.php | 216 ++++--- src/AbstractBouncer.php | 579 ++++++++++++------ src/BouncerInterface.php | 53 -- src/Configuration.php | 26 +- src/Fixes/Gregwar/Captcha/CaptchaBuilder.php | 1 + src/StandaloneBouncer.php | 120 +--- src/templates/captcha.html.twig | 2 +- src/templates/partials/captcha-js.html.twig | 2 +- tests/Integration/GeolocationTest.php | 43 +- tests/Integration/IpVerificationTest.php | 556 ++++++++++++++++- .../StandaloneBouncerNoResponse.php | 31 + tests/Integration/TestHelpers.php | 3 +- tests/Integration/WatcherClient.php | 11 + tests/PHPUnitUtil.php | 43 ++ tests/Unit/StandaloneBouncerTest.php | 396 ++++++++++++ tests/Unit/TemplateTest.php | 72 +++ tools/coding-standards/composer.json | 15 +- tools/coding-standards/phpunit/phpunit.xml | 5 +- 26 files changed, 1997 insertions(+), 901 deletions(-) delete mode 100644 src/BouncerInterface.php create mode 100644 tests/Integration/StandaloneBouncerNoResponse.php create mode 100644 tests/PHPUnitUtil.php create mode 100644 tests/Unit/StandaloneBouncerTest.php create mode 100644 tests/Unit/TemplateTest.php diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 4ab2487..0ff1e67 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -6,6 +6,11 @@ on: paths-ignore: - '**.md' workflow_dispatch: + inputs: + coverage_report: + type: boolean + description: Generate PHPUNIT Code Coverage report + default: false jobs: coding-standards: @@ -80,3 +85,27 @@ jobs: - name: Run PSALM run: ddev psalm ./${{env.EXTENSION_PATH}}/tools/coding-standards ./${{env.EXTENSION_PATH}}/tools/coding-standards/psalm + + - name: Prepare for Code Coverage + if: github.event.inputs.coverage_report == 'true' + run: | + cp .ddev/additional_docker_compose/docker-compose.crowdsec.yaml .ddev/docker-compose.crowdsec.yaml + mkdir ${{ github.workspace }}/cfssl + cp -r .ddev/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl + ddev restart + ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests + ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests + cd ${{env.EXTENSION_PATH}}/tests + sha256sum -c GeoLite2-Country.tar.gz.sha256.txt + sha256sum -c GeoLite2-City.tar.gz.sha256.txt + tar -xf GeoLite2-Country.tar.gz + tar -xf GeoLite2-City.tar.gz + rm GeoLite2-Country.tar.gz GeoLite2-Country.tar.gz.sha256.txt GeoLite2-City.tar.gz GeoLite2-City.tar.gz.sha256.txt + echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV + + - name: Run PHPUNIT Code Coverage + if: github.event.inputs.coverage_report == 'true' + run: | + ddev xdebug + ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/tools/coding-standards/vendor/bin/phpunit --configuration ./${{env.EXTENSION_PATH}}/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt + cat ${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d96ed0c..5e4007e 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -57,7 +57,6 @@ jobs: cp .ddev/additional_docker_compose/docker-compose.playwright.yaml .ddev/docker-compose.playwright.yaml mkdir ${{ github.workspace }}/cfssl cp -r .ddev/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl - ls -l ${{ github.workspace }}/cfssl ddev start - name: Set BOUNCER_KEY and PROXY_IP env @@ -84,7 +83,11 @@ jobs: run: | ddev composer update --working-dir ./${{env.EXTENSION_PATH}} - - name: Prepare PHP UNIT tests + - name: Run "Unit Tests" + run: | + ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Unit + + - name: Prepare PHP Integration and end-to-end tests run: | ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests @@ -97,23 +100,23 @@ jobs: - name: Run "IP verification with file_get_contents" test run: | - #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "IP verification with cURL" test run: | - #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "IP verification with TLS" test run: | - #ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php + ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/IpVerificationTest.php - name: Run "Geolocation with file_get_contents" test run: | - #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php - name: Run "Geolocation with cURL" test run: | - #ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl USE_CURL=1 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php - name: Prepare Standalone Bouncer end-to-end tests run: | diff --git a/.gitignore b/.gitignore index c55d0b8..ea4186f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,8 @@ tools/php-cs-fixer/composer.lock # App var/ -.bouncer-key .cache +.logs # Auto prepend demo scripts/auto-prepend/settings.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f3d5..397da9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [0.36.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.36.0) - 2023-??-?? +[_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.35.0...v0.36.0) + +### Changed +- *Breaking changes*: All the code has been refactored to use `crowdsec/remediation-engine` package: + - Lot of public methods have been deleted or replaced by others + - A bouncer should now extend an `AbstractBouncer` class and implements some new interfaced methods + - Some settings names have been changed + +--- + + ## [0.35.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.35.0) - 2022-12-16 [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.34.0...v0.35.0) diff --git a/composer.json b/composer.json index 76a80c9..5dc3e6c 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ ], "require": { "php": ">=7.2.5", - "crowdsec/remediation-engine": "0.6.0", + "crowdsec/remediation-engine": "0.6.1", "symfony/config": "^4.4.27 || ^5.2 || ^6.0", "twig/twig": "^3.4.2", "gregwar/captcha": "^1.1", @@ -49,6 +49,7 @@ "ext-gd": "*" }, "require-dev": { - "phpunit/phpunit": "^8.5.30 || ^9.3" + "phpunit/phpunit": "^8.5.30 || ^9.3", + "mikey179/vfsstream": "^1.6.11" } } diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 5856258..05edae6 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -6,51 +6,7 @@ -**Table of Contents** - -- [Local development](#local-development) - - [DDEV-Local setup](#ddev-local-setup) - - [DDEV installation](#ddev-installation) - - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) - - [DDEV Usage](#ddev-usage) - - [Add CrowdSec bouncer and watcher](#add-crowdsec-bouncer-and-watcher) - - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) - - [Find IP of your docker services](#find-ip-of-your-docker-services) - - [Unit test](#unit-test) - - [Auto-prepend mode (standalone mode)](#auto-prepend-mode-standalone-mode) - - [End-to-end tests](#end-to-end-tests) - - [Coding standards](#coding-standards) - - [PHPCS Fixer](#phpcs-fixer) - - [PHPSTAN](#phpstan) - - [PHP Mess Detector](#php-mess-detector) - - [PHPCS and PHPCBF](#phpcs-and-phpcbf) - - [PSALM](#psalm) - - [PHP Unit Code coverage](#php-unit-code-coverage) - - [Generate CrowdSec tools and settings on start](#generate-crowdsec-tools-and-settings-on-start) - - [Redis debug](#redis-debug) - - [Memcached debug](#memcached-debug) -- [Quick start guide](#quick-start-guide) - - [Check IP script](#check-ip-script) - - [Cap remediation level](#cap-remediation-level) - - [Play with other cache layers](#play-with-other-cache-layers) - - [Clear cache script](#clear-cache-script) - - [Full Live mode example](#full-live-mode-example) - - [Set up the context](#set-up-the-context) - - [Get the remediation the clean IP "1.2.3.4"](#get-the-remediation-the-clean-ip-1234) - - [Simulate LAPI down by using a bad url](#simulate-lapi-down-by-using-a-bad-url) - - [Now ban range 1.2.3.4 to 1.2.3.7 for 12h](#now-ban-range-1234-to-1237-for-12h) - - [Clear cache and get the new remediation](#clear-cache-and-get-the-new-remediation) -- [Discover the CrowdSec LAPI](#discover-the-crowdsec-lapi) - - [Use the CrowdSec cli (`cscli`)](#use-the-crowdsec-cli-cscli) - - [Add decision for an IP or a range of IPs](#add-decision-for-an-ip-or-a-range-of-ips) - - [Add decision to ban or captcha a country](#add-decision-to-ban-or-captcha-a-country) - - [Delete decisions](#delete-decisions) - - [Create a bouncer](#create-a-bouncer) - - [Create a watcher](#create-a-watcher) - - [Use the web container to call LAPI](#use-the-web-container-to-call-lapi) -- [Commit message](#commit-message) - - [Allowed message `type` values](#allowed-message-type-values) -- [Release process](#release-process) + @@ -60,12 +16,12 @@ There are many ways to install this library on a local PHP environment. -We are using [DDEV-Local](https://ddev.readthedocs.io/en/stable/) because it is quite simple to use and customize. +We are using [DDEV](https://ddev.readthedocs.io/en/stable/) because it is quite simple to use and customize. Of course, you may use your own local stack, but we provide here some useful tools that depends on DDEV. -### DDEV-Local setup +### DDEV setup For a quick start, follow the below steps. @@ -190,9 +146,15 @@ ddev find-ip ddev-router ``` - #### Unit test + +```bash +ddev php ./my-own-modules/crowdsec-php-lib/vendor/bin/phpunit ./my-own-modules/crowdsec-php-lib/tests/Unit --testdox +``` + +#### Integration test + First, create a bouncer and keep the result key. ```bash @@ -222,8 +184,7 @@ and`GeoLite2-Country.mmdb`. You can download these databases by creating a maxmi Then, you can run: ```bash -ddev exec BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 -/usr/bin/php ./my-own-modules/crowdsec-php-lib/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-own-modules/crowdsec-php-lib/tests/Integration/GeolocationTest.php +ddev exec BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 /usr/bin/php ./my-own-modules/crowdsec-php-lib/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-own-modules/crowdsec-php-lib/tests/Integration/GeolocationTest.php ``` **N.B**: If you want to test with `curl` instead of `file_get_contents` calls to LAPI, you have to add `USE_CURL=1` in @@ -370,8 +331,7 @@ ddev xdebug To generate a html report, you can run: ```bash -ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=your-bouncer-key LAPI_URL=https://crowdsec:8080 -MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-own-modules/crowdsec-php-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-own-modules/crowdsec-php-lib/tools/coding-standards/phpunit/phpunit.xml +ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php ./my-own-modules/crowdsec-php-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-own-modules/crowdsec-php-lib/tools/coding-standards/phpunit/phpunit.xml ``` @@ -447,119 +407,12 @@ the max number of keys to dump: - `delete `: Delete a key -## Quick start guide - -> Goal: At the end of this guide, you will understand better: -> - the live mode as well as the stream mode for even more performance -> - the cache layers you can use in this library (File System, Redis, Memcached, and more) -> - the cap remediation level -> - how to get the logged events +## Example scripts You will find some php scripts in the `scripts` folder. **N.B** : If you are not using DDEV, you can replace all `ddev exec php ` by `php` and specify the right script paths. -### Check IP script - -The [`check-ip`](../scripts/check-ip.php) script will get the remediation (`bypass`, `captcha` or `ban`) for some IP. - -To run this script, you have to know your bouncer key `` and run -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/check-ip.php -``` - -As a reminder, your bouncer key is returned by the `ddev create-bouncer` command. - -For example, run the php script: - -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/check-ip.php 1.2.3.4 -``` - -As your CrowdSec instance contains no decisions, you received the result "bypass". - -Let's now add a new decision in CrowdSec, for example we will ban the 1.2.3.4/30 for 4h: - -```bash -ddev exec -s crowdsec cscli decisions add --range 1.2.3.4/30 --duration 4h --type ban -``` - -Now, if you run the php script against the `1.2.3.4` IP: - -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/check-ip.php 1.2.3.4 -``` - -LAPI will advise you to ban this IP as it's within the 1.2.3.4/30 range. - -#### Cap remediation level - -In some cases, it's a critical action to ban access to users (ex: e-commerce). We prefer to let user access to the website, even if CrowdSec says "ban it!". - -Fortunately, this library allows you to cap the remediation to a certain level. - -Let's add the `max_remediation_level` configuration with `captcha` value: - -```php -$configs = [ - 'api_key' => $bouncerKey, - 'api_url' => 'https://crowdsec:8080', - 'fs_cache_path' => __DIR__ . '/.cache' - 'max_remediation_level' => 'captcha' // <== ADD THIS LINE - ]; -``` - -Now if you call one more time: - -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/check-ip.php 1.2.3.4 -``` - -The library will cap the value to `captcha` level. - - -#### Play with other cache layers - -Now update the `check-ip.php`script to replace the `PhpFilesAdapter` with the `RedisAdapter`. - -Replace: - -```php -$configs = [ - 'api_key' => $bouncerKey, - 'api_url' => 'https://crowdsec:8080', - 'fs_cache_path' => __DIR__ . '/.cache', -]; -``` - -with: - -```php -$configs = [ - 'api_key' => $bouncerKey, - 'api_url' => 'https://crowdsec:8080', - 'cache_system' => 'redis', - 'redis_dsn' => 'redis://redis:6379' -]; -``` - -Or, if `Memcached` is more adapted than `Redis` to your needs: - -```php -$configs = [ - 'api_key' => $bouncerKey, - 'api_url' => 'https://crowdsec:8080', - 'cache_system' => 'memcached', - 'memcached_dsn' => 'memcached://memcached:11211' -]; -``` - -You will still be able to verify IPs, but the cache system will be more efficient. - -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/check-ip.php 1.2.3.4 -``` - ### Clear cache script To clear your LAPI cache, you can use the [`clear-php`](../scripts/clear-cache.php) script: @@ -572,7 +425,7 @@ ddev exec php my-own-modules/crowdsec-php-lib/scripts/clear-cache.php 1.2.3.4 https://crowdsec:8080 +ddev exec php my-own-modules/crowdsec-php-lib/scripts/standalone-check-ip-live.php 1.2.3.4 ``` -#### Simulate LAPI down by using a bad url - -If you run this script twice, LAPI will not be called, the cache system will relay the information. -You can this behaviour by testing with a bad LAPI url. - -```bash -ddev exec php my-own-modules/crowdsec-php-lib/scripts/full-example-live-mode.php 1.2.3.4 https://crowdsec:BAD -``` - -As you can see, you can check the API event if LAPI is down. This is because of the caching system. - #### Now ban range 1.2.3.4 to 1.2.3.7 for 12h ```bash @@ -625,7 +467,7 @@ ddev exec php my-own-modules/crowdsec-php-lib/scripts/clear-cache.php 1.2.3.4 https://crowdsec:8080 +ddev exec php my-own-modules/crowdsec-php-lib/scripts/standalone-check-ip-live.php 1.2.3.4 ``` This is a ban (and cache miss) as you can see in your terminal logs. @@ -780,7 +622,7 @@ Before publishing a new release, there are some manual steps to take: Then, you have to [run the action manually from the GitHub repository](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/release.yml) -Alternatively, you could use the [Github CLI](https://github.com/cli/cli): +Alternatively, you could use the [GitHub CLI](https://github.com/cli/cli): - create a draft release: ``` gh workflow run release.yml -f tag_name=vx.y.z -f draft=true @@ -794,7 +636,7 @@ gh workflow run release.yml -f tag_name=vx.y.z -f prerelease=true gh workflow run release.yml -f tag_name=vx.y.z ``` -Note that the Github action will fail if the tag `tag_name` already exits. +Note that the GitHub action will fail if the tag `tag_name` already exits. diff --git a/docs/INSTALLATION_GUIDE.md b/docs/INSTALLATION_GUIDE.md index 1fb4555..4391f04 100644 --- a/docs/INSTALLATION_GUIDE.md +++ b/docs/INSTALLATION_GUIDE.md @@ -7,24 +7,14 @@ -**Table of Contents** -- [Requirements](#requirements) -- [Installation](#installation) -- [Standalone mode installation](#standalone-mode-installation) - - [Files permission](#files-permission) - - [Settings file](#settings-file) - - [`auto_prepend_file` directive](#auto_prepend_file-directive) - - [`.ini` file](#ini-file) - - [Nginx](#nginx) - - [Apache](#apache) ## Requirements -- PHP >= 7.2 +- PHP >= 7.2.5 - required php extensions: `ext-json`, `ext-gd` - suggested php extension: `ext-curl` @@ -35,63 +25,3 @@ Use `Composer` by simply adding `crowdsec/bouncer` as a dependency: composer require crowdsec/bouncer -## Standalone mode installation - -This library can also be used on its own so that every browser access to a php script will be bounced. - -In order to use the standalone mode, you will have to : - -- give the correct permission for the folder that contains the lib - -- copy the `scripts/auto-prepend/settings.example.php` to a `scripts/auto-prepend/settings.php` file - -- set an `auto_prepend_file` directive in your PHP setup. - - -### Files permission - -The owner of the `/path/to/the/crowdsec-lib` should be your webserver owner (e.g. `www-data`). - -You can achieve it by running command like: - -``` -sudo chown www-data /path/to/the/crowdsec-lib -``` - -### Settings file - -Please copy the `scripts/auto-prepend/settings.example.php` to a `scripts/auto-prepend/settings.php` -and fill the necessary settings in it (see [Configurations settings](./USER_GUIDE.md/#configurations) for more details). - -### `auto_prepend_file` directive - -We will now describe how to set an `auto_prepend_file` directive in order to call the `scripts/auto-prepend/bounce.php` for each php script access. - -Adding an `auto_prepend_file` directive can be done in different ways: - -#### `.ini` file - -You should add this line to a `.ini` file : - - auto_prepend_file = /absolute/path/to/scripts/auto-prepend/bounce.php - -#### Nginx - -If you are using Nginx, you should modify your nginx configuration file by adding a `fastcgi_param` -directive. The php block should look like below: - -``` -location ~ \.php$ { - ... - ... - ... - fastcgi_param PHP_VALUE "/absolute/path/to/scripts/auto-prepend/bounce.php"; -} -``` - -#### Apache - -If you are using Apache, you should add this line to your `.htaccess` file: - - php_value auto_prepend_file "/absolute/path/to/scripts/auto-prepend/bounce.php" - diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2771ad1..22b9679 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -6,24 +6,7 @@ -**Table of Contents** - -- [Description](#description) -- [Prerequisites](#prerequisites) -- [Features](#features) -- [Usage](#usage) - - [Create your own bouncer](#create-your-own-bouncer) - - [Quick start](#quick-start) - - [Test your bouncer](#test-your-bouncer) - - [Configurations](#configurations) - - [Local API Connection](#local-api-connection) - - [Debug](#debug) - - [Bouncer behavior](#bouncer-behavior) - - [Cache](#cache) - - [Geolocation](#geolocation) - - [Captcha and ban wall settings](#captcha-and-ban-wall-settings) - - [The `Standalone` example](#the-standalone-example) - - [Ready to use PHP bouncers](#ready-to-use-php-bouncers) + @@ -31,7 +14,8 @@ ## Description This library allows you to create CrowdSec bouncers for PHP applications or frameworks like e-commerce, blog or other -exposed applications. It can also be used in a standalone mode using auto-prepend directive. +exposed applications. It can also be used in a standalone mode using auto-prepend directive and the provided standalone +bouncer. ## Prerequisites @@ -43,57 +27,196 @@ Please note that first and foremost a CrowdSec agent must be installed on a serv ## Features - CrowdSec Local API Support - - Handle IP, IP ranges and Country scoped decisions + - Handle `ip`, `range` and `country` scoped decisions - Clear, prune and refresh the Local API cache - `Live mode` or `Stream mode` +- Support IpV4 and Ipv6 (Ipv6 range decisions are yet only supported in `Live mode`) - Large PHP matrix compatibility: 7.2, 7.3, 7.4, 8.0, 8.1 and 8.2 -- Built-in support for the most known cache systems like Redis, Memcached, PhpFiles -- Events logged using monolog +- Built-in support for the most known cache systems Redis, Memcached and PhpFiles - Cap remediation level (ex: for sensitives websites: ban will be capped to captcha) ## Usage -When a user is suspected by CrowdSec to be malevolent, a bouncer will either send him/her a captcha to resolve or -simply a page notifying that access is denied. If the user is considered as a clean user, he will access the page as normal. +When a user is suspected by CrowdSec to be malevolent, a bouncer would either display a captcha to resolve or +simply a page notifying that access is denied. If the user is considered as a clean user, he/she will access the page +as normal. -By default, the ban wall is displayed as below: +A ban wall could look like: ![Ban wall](images/screenshots/front-ban.jpg) -By default, the captcha wall is displayed as below: +A captcha wall could look like: ![Captcha wall](images/screenshots/front-captcha.jpg) -Please note that it is possible to customize all the colors of these pages so that they integrate best with your design. +With the provided Standalone bouncer, please note that it is possible to customize all the colors of these pages so +that they integrate best with your design. On the other hand, all texts are also fully customizable. This will allow you, for example, to present translated pages in your users' language. -### Create your own bouncer +## Standalone bouncer set up + +This library includes the [`StandaloneBouncer`](../src/StandaloneBouncer.php) class. You can see that class as a good +example for creating your own bouncer. + +Once you set up your server as below, every browser access to a php script will be bounced by the Standalone bouncer. + +You will have to : + +- give the correct permission for the folder that contains the lib + +- copy the `scripts/auto-prepend/settings.example.php` to a `scripts/auto-prepend/settings.php` file + +- set an `auto_prepend_file` directive in your PHP setup. + + +### Files permission + +The owner of the `/path/to/the/crowdsec-lib` should be your webserver owner (e.g. `www-data`). + +You can achieve it by running command like: + +``` +sudo chown www-data /path/to/the/crowdsec-lib +``` + +### Settings file + +Please copy the `scripts/auto-prepend/settings.example.php` to a `scripts/auto-prepend/settings.php` +and fill the necessary settings in it (see [Configurations settings](#configurations) for more details). + +### `auto_prepend_file` directive + +We will now describe how to set an `auto_prepend_file` directive in order to call the `scripts/auto-prepend/bounce.php` for each php script access. + +Adding an `auto_prepend_file` directive can be done in different ways: + +#### `.ini` file + +You should add this line to a `.ini` file : + + auto_prepend_file = /absolute/path/to/scripts/auto-prepend/bounce.php + +#### Nginx + +If you are using Nginx, you should modify your nginx configuration file by adding a `fastcgi_param` +directive. The php block should look like below: + +``` +location ~ \.php$ { + ... + ... + ... + fastcgi_param PHP_VALUE "/absolute/path/to/scripts/auto-prepend/bounce.php"; +} +``` + +#### Apache + +If you are using Apache, you should add this line to your `.htaccess` file: + + php_value auto_prepend_file "/absolute/path/to/scripts/auto-prepend/bounce.php" + + +## Create your own bouncer + +### Implementation You can use this library to develop your own PHP application bouncer. Any custom bouncer should extend the -[`AbstractBounce`](../src/AbstractBounce.php) class. +[`AbstractBouncer`](../src/AbstractBouncer.php) class. + +```php +namespace MyNameSpace; +use CrowdSecBouncer\AbstractBouncer; + +class MyCustomBouncer extends AbstractBouncer +{ + + +} +``` -#### Quick start +Then, you will have to implement all necessary methods : + +```php +namespace MyNameSpace; +use CrowdSecBouncer\AbstractBouncer; + +class MyCustomBouncer extends AbstractBouncer +{ + + /** + * Get current http method + */ + public function getHttpMethod(): string + { + // Your implementation + } + + /** + * Get value of an HTTP request header. Ex: "X-Forwarded-For" + */ + public function getHttpRequestHeader(string $name): ?string + { + // Your implementation + } + + /** + * Get the value of a posted field. + */ + public function getPostedVariable(string $name): ?string + { + // Your implementation + } + + /** + * Get the current IP, even if it's the IP of a proxy + */ + public function getRemoteIp(): string + { + // Your implementation + } + + /** + * Get current request uri + */ + public function getRequestUri(): string + { + // Your implementation + } + +} +``` + + +Once you have implemented these methods, you could retrieve all required configurations to instantiate your +bouncer and then call the `safelyBounce` method to apply a bounce for the current detected IP. + +In order to instantiate the bouncer, you will have to create at least a `CrowdSec\RemediationEngine\LapiRemediation` +object too. -In your PHP project, just add these lines to verify an IP: ```php +use MyNameSpace\MyCustomBouncer; +use CrowdSec\RemediationEngine\LapiRemediation; +use CrowdSec\LapiClient\Bouncer as BouncerClient; +use CrowdSec\RemediationEngine\CacheStorage\PhpFiles; -configure(['api_key' => 'YOUR_BOUNCER_API_KEY', 'api_url' => 'http://127.0.0.1:8080']); +$bouncer = new MyCustomBouncer($configs, $remediationEngine); + +$bouncer->safelyBounce(); -// Ask remediation to API -$remediation = $bouncer->getRemediationForIp($requestedIp); -echo "\nResult: $remediation\n\n"; // "ban", "captcha" or "bypass" ``` -#### Test your bouncer + +### Test your bouncer To test your bouncer, you could add decision to ban your own IP for 5 minutes for example: @@ -109,14 +232,33 @@ cscli decisions add --ip --duration 15m --type captcha ``` -#### Configurations +To go further and learn how to include this library in your +project, you should follow the [`DEVELOPER GUIDE`](DEVELOPER.md). -You can pass an array of configurations in the `$bouncer->configure($configs)` method. +## Configurations + +You can pass an array of configurations in the bouncer constructor. Please look at the [Settings example file](../scripts/auto-prepend/settings.example.php) for quick overview. Here is the list of available settings: -##### Local API Connection +### Bouncer behavior + +- `bouncing_level`: Select from `bouncing_disabled`, `normal_bouncing` or `flex_bouncing`. Choose if you want to apply CrowdSec directives (Normal bouncing) or be more permissive (Flex bouncing). With the `Flex mode`, it is impossible to accidentally block access to your site to people who don’t deserve it. This mode makes it possible to never ban an IP but only to offer a Captcha, in the worst-case scenario. + + +- `fallback_remediation`: Select from `bypass` (minimum remediation), `captcha` or `ban` (maximum remediation). Default to 'captcha'. Handle unknown remediations as. + + +- `trust_ip_forward_array`: If you use a CDN, a reverse proxy or a load balancer, set an array of IPs. For other IPs, the bouncer will not trust the X-Forwarded-For header. + + +- `excluded_uris`: array of URIs that will not be bounced. + + +- `stream_mode`: true to enable stream mode, false to enable the live mode. Default to false. By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode means that the IP will be checked directly by the CrowdSec API. The rest of your user’s browsing will be even more transparent thanks to the fully customizable cache system. But you can also activate the `stream mode`. This mode allows you to constantly feed the bouncer with the malicious IP list via a background task (CRON), making it to be even faster when checking the IP of your visitors. Besides, if your site has a lot of unique visitors at the same time, this will not influence the traffic to the API of your CrowdSec instance. + +### Local API Connection - `auth_type`: Select from `api_key` and `tls`. Choose if you want to use an API-KEY or a TLS (pki) authentification. TLS authentication is only available if you use CrowdSec agent with a version superior to 1.4.0. @@ -135,7 +277,7 @@ Here is the list of available settings: - `tls_verify_peer`: This option determines whether request handler verifies the authenticity of the peer's certificate. - Only required if you choose `tls` as `auth_type`. + Only required if you choose `tls` as `auth_type`. When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. If `tls_verify_peer` is set to true, request handler verifies whether the certificate is authentic. This trust is based on a chain of digital signatures, @@ -149,7 +291,7 @@ Here is the list of available settings: - `api_url`: Define the URL to your Local API server, default to `http://localhost:8080`. -- `api_timeout`: In seconds. The timeout when calling Local API. Default to 120 sec. If set to a negative value, +- `api_timeout`: In seconds. The timeout when calling Local API. Default to 120 sec. If set to a negative value, timeout will be unlimited. @@ -157,50 +299,13 @@ Here is the list of available settings: You can set `use_curl` to `true` in order to use `cURL` request instead (`curl` is in then required) -##### Debug -- `debug_mode`: `true` to enable verbose debug log. Default to `false`. - - -- `disable_prod_log`: `true` to disable prod log. Default to `false`. - - -- `log_directory_path`: Absolute path to store log files. Important note: be sur this path won't be publicly - accessible. - - -- `display_errors`: true to stop the process and display errors on browser if any. - - -- `forced_test_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used instead of the - real remote ip. - - -- `forced_test_forwarded_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used - instead of the real forwarded ip. If set to `no_forward`, the x-forwarded-for mechanism will not be used at all. - -##### Bouncer behavior - -- `bouncing_level`: Select from `bouncing_disabled`, `normal_bouncing` or `flex_bouncing`. Choose if you want to apply CrowdSec directives (Normal bouncing) or be more permissive (Flex bouncing). With the `Flex mode`, it is impossible to accidentally block access to your site to people who don’t deserve it. This mode makes it possible to never ban an IP but only to offer a Captcha, in the worst-case scenario. - - -- `fallback_remediation`: Select from `bypass` (minimum remediation), `captcha` or `ban` (maximum remediation). Default to 'captcha'. Handle unknown remediations as. - - -- `max_remediation_level`: Select from `bypass`,`captcha` or `ban`. Default to 'ban'. Cap the - remediation to the selected one. - - -- `trust_ip_forward_array`: If you use a CDN, a reverse proxy or a load balancer, set an array of IPs. For other IPs, the bouncer will not trust the X-Forwarded-For header. - -- `excluded_uris`: array of URIs that will not be bounced. - -##### Cache +### Cache - `cache_system`: Select from `phpfs` (File system cache), `redis` or `memcached`. -- `fs_cache_path`: Will be used only if you choose File system as cache_system. Important note: be sur this path +- `fs_cache_path`: Will be used only if you choose File system as cache_system. Important note: be sur this path won't be publicly accessible. @@ -216,121 +321,107 @@ Here is the list of available settings: - `bad_ip_cache_duration`: Set the duration we keep in cache the fact that an IP is bad. In seconds. Defaults to 20. -- `captcha_cache_duration`: Set the duration we keep in cache the captcha flow variables for an IP. In seconds. +- `captcha_cache_duration`: Set the duration we keep in cache the captcha flow variables for an IP. In seconds. Defaults to 86400.. In seconds. Defaults to 20. -- `geolocation_cache_duration`: Set the duration we keep in cache a geolocation result for an IP . In seconds. - Defaults to 86400. Depends on the below `geolocation[save_result]` configuration. +### Geolocation +- `geolocation`: Settings for geolocation remediation (i.e. country based remediation). -- `stream_mode`: true to enable stream mode, false to enable the live mode. Default to false. By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode means that the IP will be checked directly by the CrowdSec API. The rest of your user’s browsing will be even more transparent thanks to the fully customizable cache system. But you can also activate the `stream mode`. This mode allows you to constantly feed the bouncer with the malicious IP list via a background task (CRON), making it to be even faster when checking the IP of your visitors. Besides, if your site has a lot of unique visitors at the same time, this will not influence the traffic to the API of your CrowdSec instance. + - `geolocation[enabled]`: true to enable remediation based on country. Default to false. -##### Geolocation + - `geolocation[type]`: Geolocation system. Only 'maxmind' is available for the moment. Default to `maxmind`. -- `geolocation`: Settings for geolocation remediation (i.e. country based remediation). + - `geolocation[cache_duration]`: This setting will be used to set the lifetime (in seconds) of a cached country + associated to an IP. The purpose is to avoid multiple call to the geolocation system (e.g. maxmind database). Default to 86400. Set 0 to disable caching. -- `geolocation[enabled]`: true to enable remediation based on country. Default to false. + - `geolocation[maxmind]`: MaxMind settings. -- `geolocation[type]`: Geolocation system. Only 'maxmind' is available for the moment. Default to `maxmind`. + - `geolocation[maxmind][database_type]`: Select from `country` or `city`. Default to `country`. These are the two available MaxMind database types. - -- `geolocation[save_result]`: true to store the geolocalized country in cache. Default to true. Setting true - will avoid multiple call to the geolocalized system (e.g. maxmind database). + - `geolocation[maxmind][database_path]`: Absolute path to the MaxMind database (e.g. mmdb file) -- `geolocation[maxmind]`: MaxMind settings. -- `geolocation[maxmind][database_type]`: Select from `country` or `city`. Default to `country`. These are the two available MaxMind database types. +### Captcha and ban wall settings -- `geolocation[maxmind][database_path]`: Absolute path to the MaxMind database (e.g. mmdb file) +- `hide_mentions`: true to hide CrowdSec mentions on ban and captcha walls. -##### Captcha and ban wall settings -- `hide_mentions`: true to hide CrowdSec mentions on ban and captcha walls. +- `custom_css`: Custom css directives for ban and captcha walls -- Wording and css settings: - `theme_color_text_primary` +- `color`: Array of settings for ban and captcha walls colors. - `theme_color_text_secondary` + - `color[text][primary]` - `theme_color_text_button` + - `color[text][secondary]` - `theme_color_text_error_message` + - `color[text][button]` - `theme_color_background_page` + - `color[text][error_message]` - `theme_color_background_container` + - `color[background][page]` - `theme_color_background_button` + - `color[background][container]` - `theme_color_background_button_hover` + - `color[background][button]` - `theme_custom_css` + - `color[background][button_hover]` - `theme_text_captcha_wall_tab_title` - `theme_text_captcha_wall_title` +- `text`: Array of settings for ban and captcha walls texts. - `theme_text_captcha_wall_subtitle` + - `text[captcha_wall][tab_title]` - `theme_text_captcha_wall_refresh_image_link` + - `text[captcha_wall][title]` - `theme_text_captcha_wall_captcha_placeholder` + - `text[captcha_wall][subtitle]` - `theme_text_captcha_wall_send_button` + - `text[captcha_wall][refresh_image_link]` - `theme_text_captcha_wall_error_message` + - `text[captcha_wall][captcha_placeholder]` - `theme_text_captcha_wall_footer` + - `text[captcha_wall][send_button]` - `theme_text_ban_wall_tab_title` + - `text[captcha_wall][error_message]` - `theme_text_ban_wall_title` + - `text[captcha_wall][footer]` - `theme_text_ban_wall_subtitle` + - `text[ban_wall][tab_title]` - `theme_text_ban_wall_footer` + - `text[ban_wall][title]` + - `text[ban_wall][subtitle]` -#### The `Standalone` example + - `text[ban_wall][footer]` -This library includes the [`StandaloneBounce`](../src/StandaloneBounce.php) class. You can see that class as a good -example for creating your own bouncer. This class extends [`AbstractBounce`](../src/AbstractBounce.php). All bouncers should do the same. In order to add the bounce logic, you -should first instantiate your bouncer: -```php -use \CrowdSecBouncer\StandaloneBounce -$bounce = new StandaloneBounce(); -``` -And then, you should initialize the bouncer by passing all the configuration array in a `init` method: +### Debug +- `debug_mode`: `true` to enable verbose debug log. Default to `false`. -```php -$configs = [...] // @See below for configuration details -$bouncer = $bounce->init($configs) -``` -Finally, you can bounce by calling: +- `disable_prod_log`: `true` to disable prod log. Default to `false`. -```php -$bouncer->run(); -``` -If you have implemented a `safelyBounce` method (like in [`StandaloneBounce`](../src/StandaloneBounce.php) class), -you can just do: +- `log_directory_path`: Absolute path to store log files. Important note: be sur this path won't be publicly + accessible. + + +- `display_errors`: true to stop the process and display errors on browser if any. + + +- `forced_test_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used instead of the + real remote ip. + + +- `forced_test_forwarded_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used + instead of the real forwarded ip. If set to `no_forward`, the x-forwarded-for mechanism will not be used at all. -```php -use \CrowdSecBouncer\StandaloneBounce -$bounce = new StandaloneBounce(); -$configs = [...] // Retrieve configs from somewhere (database, static file, etc) -$bounce->safelyBounce($configs); -``` -To go further and learn how to include this library in your -project, you should follow the [`DEVELOPER GUIDE`](DEVELOPER.md). -#### Ready to use PHP bouncers +## Other ready to use PHP bouncers To have a more concrete idea on how to develop a bouncer, you may look at the following bouncers for Magento 2 and WordPress : diff --git a/scripts/auto-prepend/settings.example.php b/scripts/auto-prepend/settings.example.php index e7a37a7..e9f1a31 100644 --- a/scripts/auto-prepend/settings.example.php +++ b/scripts/auto-prepend/settings.example.php @@ -4,6 +4,118 @@ use CrowdSecBouncer\Constants; $crowdSecStandaloneBouncerConfig = [ + // ============================================================================# + // Bouncer configs + // ============================================================================# + + /** Select from 'bouncing_disabled', 'normal_bouncing' or 'flex_bouncing'. + * + * Choose if you want to apply CrowdSec directives (Normal bouncing) or be more permissive (Flex bouncing). + * With the `Flex mode`, it is impossible to accidentally block access to your site to people who don’t + * deserve it. This mode makes it possible to never ban an IP but only to offer a Captcha, in the worst-case + * scenario. + */ + 'bouncing_level' => Constants::BOUNCING_LEVEL_NORMAL, + + /** If you use a CDN, a reverse proxy or a load balancer, you can use this setting to whitelist their IPs + * + * For other IPs, the bouncer will not trust the X-Forwarded-For header. + * + * With the Standalone bouncer, you have to set an array of Ips : ['1.2.3.4', '5.6.7.8'] for example + * The standalone bouncer will automatically transform this array to an array of comparable IPs arrays: + * [['001.002.003.004', '001.002.003.004'], ['005.006.007.008', '005.006.006.007']] + * + * If you use your own bouncer, you should have to set directly an array of comparable IPs arrays + * + */ + 'trust_ip_forward_array' => [], + /** + * By default, the lib call the REST LAPI using file_get_contents method (allow_url_fopen is required). + * Set 'use_curl' to true in order to use cURL request instead (curl is in then required). + */ + 'use_curl' => false, + + /** + * array of URIs that will not be bounced. + */ + 'excluded_uris' => ['/favicon.ico'], + + // Select from 'phpfs' (File system cache), 'redis' or 'memcached'. + 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, + + // Set the duration we keep in cache the captcha flow variables for an IP. In seconds. Defaults to 86400. + 'captcha_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CAPTCHA, + + // true to enable verbose debug log. + 'debug_mode' => false, + // true to disable prod log + 'disable_prod_log' => false, + + /** Absolute path to store log files. + * + * Important note: be sur this path won't be publicly accessible + */ + 'log_directory_path' => __DIR__ . '/.logs', + + // true to stop the process and display errors if any. + 'display_errors' => false, + + /** Only for test or debug purpose. Default to empty. + * + * If not empty, it will be used instead of the real remote ip. + */ + 'forced_test_ip' => '', + + /** Only for test or debug purpose. Default to empty. + * + * If not empty, it will be used instead of the real forwarded ip. + * If set to "no_forward", the x-forwarded-for mechanism will not be used at all. + */ + 'forced_test_forwarded_ip' => '', + + // Settings for ban and captcha walls + 'custom_css' => '', + // true to hide CrowdSec mentions on ban and captcha walls. + 'hide_mentions' => false, + 'color' => [ + 'text' => [ + 'primary' => 'black', + 'secondary' => '#AAA', + 'button' => 'white', + 'error_message' => '#b90000', + ], + 'background' => [ + 'page' => '#eee', + 'container' => 'white', + 'button' => '#626365', + 'button_hover' => '#333', + ], + ], + 'text' => [ + // Settings for captcha wall + 'captcha_wall' => [ + 'tab_title' => 'Oops..', + 'title' => 'Hmm, sorry but...', + 'subtitle' => 'Please complete the security check.', + 'refresh_image_link' => 'refresh image', + 'captcha_placeholder' => 'Type here...', + 'send_button' => 'CONTINUE', + 'error_message' => 'Please try again.', + 'footer' => '', + ], + // Settings for ban wall + 'ban_wall' => [ + 'tab_title' => 'Oops..', + 'title' => '🤭 Oh!', + 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', + 'footer' => '', + ], + ], + + // ============================================================================# + // Client configs + // ============================================================================# + /** Select from 'api_key' and 'tls'. * * Choose if you want to use an API-KEY or a TLS (pki) authentification @@ -55,47 +167,9 @@ // In seconds. The timeout when calling LAPI. Must be greater or equal than 1. Defaults to 1 sec. 'api_timeout' => 1, - /** - * By default, the lib call the REST LAPI using file_get_contents method (allow_url_fopen is required). - * Set 'use_curl' to true in order to use cURL request instead (curl is in then required). - */ - 'use_curl' => false, - - // true to enable verbose debug log. - 'debug_mode' => false, - // true to disable prod log - 'disable_prod_log' => false, - - /** Absolute path to store log files. - * - * Important note: be sur this path won't be publicly accessible - */ - 'log_directory_path' => __DIR__ . '/.logs', - - // true to stop the process and display errors if any. - 'display_errors' => false, - - /** Only for test or debug purpose. Default to empty. - * - * If not empty, it will be used instead of the real remote ip. - */ - 'forced_test_ip' => '', - - /** Only for test or debug purpose. Default to empty. - * - * If not empty, it will be used instead of the real forwarded ip. - * If set to "no_forward", the x-forwarded-for mechanism will not be used at all. - */ - 'forced_test_forwarded_ip' => '', - - /** Select from 'bouncing_disabled', 'normal_bouncing' or 'flex_bouncing'. - * - * Choose if you want to apply CrowdSec directives (Normal bouncing) or be more permissive (Flex bouncing). - * With the `Flex mode`, it is impossible to accidentally block access to your site to people who don’t - * deserve it. This mode makes it possible to never ban an IP but only to offer a Captcha, in the worst-case - * scenario. - */ - 'bouncing_level' => Constants::BOUNCING_LEVEL_NORMAL, + // ============================================================================# + // Remediation engine configs + // ============================================================================# /** Select from 'bypass' (minimum remediation), 'captcha' or 'ban' (maximum remediation). * Default to 'captcha'. @@ -104,19 +178,15 @@ */ 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, - /** If you use a CDN, a reverse proxy or a load balancer, set an array of IPs. - * - * For other IPs, the bouncer will not trust the X-Forwarded-For header. - */ - 'trust_ip_forward_array' => [], - /** - * array of URIs that will not be bounced. + * The `ordered_remediations` setting accepts an array of remediations ordered by priority. + * If there are more than one decision for an IP, remediation with the highest priority will be return. + * The specific remediation `bypass` will always be considered as the lowest priority (there is no need to + * specify it in this setting). + * This setting is not required. If you don't set any value, `['ban']` will be used by default for CAPI remediation + * and `['ban', 'captcha']` for LAPI remediation. */ - 'excluded_uris' => ['/favicon.ico'], - - // Select from 'phpfs' (File system cache), 'redis' or 'memcached'. - 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, + 'ordered_remediations' => [Constants::REMEDIATION_BAN, Constants::REMEDIATION_CAPTCHA], /** Will be used only if you choose File system as cache_system. * @@ -136,9 +206,6 @@ // Set the duration we keep in cache the fact that an IP is bad. In seconds. Defaults to 20. 'bad_ip_cache_duration' => Constants::CACHE_EXPIRATION_FOR_BAD_IP, - // Set the duration we keep in cache the captcha flow variables for an IP. In seconds. Defaults to 86400. - 'captcha_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CAPTCHA, - /** true to enable stream mode, false to enable the live mode. Default to false. * * By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode @@ -175,43 +242,4 @@ 'database_path' => '/some/path/GeoLite2-Country.mmdb', ], ], - - // Settings for ban and captcha walls - 'custom_css' => '', - // true to hide CrowdSec mentions on ban and captcha walls. - 'hide_mentions' => false, - 'color' => [ - 'text' => [ - 'primary' => 'black', - 'secondary' => '#AAA', - 'button' => 'white', - 'error_message' => '#b90000', - ], - 'background' => [ - 'page' => '#eee', - 'container' => 'white', - 'button' => '#626365', - 'button_hover' => '#333', - ], - ], - 'text' => [ - // Settings for captcha wall - 'captcha_wall' => [ - 'tab_title' => 'Oops..', - 'title' => 'Hmm, sorry but...', - 'subtitle' => 'Please complete the security check.', - 'refresh_image_link' => 'refresh image', - 'captcha_placeholder' => 'Type here...', - 'send_button' => 'CONTINUE', - 'error_message' => 'Please try again.', - 'footer' => '', - ], - // Settings for ban wall - 'ban_wall' => [ - 'tab_title' => 'Oops..', - 'title' => '🤭 Oh!', - 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', - 'footer' => '', - ], - ], ]; diff --git a/src/AbstractBouncer.php b/src/AbstractBouncer.php index 2979a61..a91d601 100644 --- a/src/AbstractBouncer.php +++ b/src/AbstractBouncer.php @@ -7,8 +7,8 @@ use CrowdSec\LapiClient\Bouncer as BouncerClient; use CrowdSec\LapiClient\RequestHandler\Curl; use CrowdSec\LapiClient\RequestHandler\FileGetContents; -use CrowdSec\RemediationEngine\CacheStorage\AbstractCache; use CrowdSec\RemediationEngine\AbstractRemediation; +use CrowdSec\RemediationEngine\CacheStorage\AbstractCache; use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; use CrowdSec\RemediationEngine\CacheStorage\Memcached; use CrowdSec\RemediationEngine\CacheStorage\PhpFiles; @@ -33,7 +33,7 @@ * @copyright Copyright (c) 2021+ CrowdSec * @license MIT License */ -abstract class AbstractBouncer implements BouncerInterface +abstract class AbstractBouncer { /** @var array */ protected $configs = []; @@ -47,10 +47,12 @@ public function __construct( AbstractRemediation $remediationEngine, LoggerInterface $logger = null ) { + // @codeCoverageIgnoreStart if (!$logger) { $logger = new Logger('null'); $logger->pushHandler(new NullHandler()); } + // @codeCoverageIgnoreEnd $this->logger = $logger; $this->configure($configs); $this->remediationEngine = $remediationEngine; @@ -67,19 +69,23 @@ public function __construct( } /** - * Build a captcha couple. + * Apply a bounce for current IP depending on remediation associated to this IP + * (e.g. display a ban wall, captcha wall or do nothing in case of a bypass) * - * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" - * representing the image data + * @return void + * @throws BouncerException + * @throws CacheException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ - private static function buildCaptchaCouple(): array + public function bounceCurrentIp(): void { - $captchaBuilder = new CaptchaBuilder(); - - return [ - 'phrase' => $captchaBuilder->getPhrase(), - 'inlineImage' => $captchaBuilder->build()->inline(), - ]; + // Retrieve the current IP (even if it is a proxy IP) or a testing IP + $forcedTestIp = $this->getConfig('forced_test_ip'); + $ip = $forcedTestIp ?: $this->getRemoteIp(); + $ip = $this->handleForwardedFor($ip, $this->configs); + $remediation = $this->getRemediationForIp($ip); + $this->handleRemediation($remediation, $ip); } /** @@ -87,6 +93,7 @@ private static function buildCaptchaCouple(): array * * @return bool If the cache has been successfully cleared or not * + * */ public function clearCache(): bool { @@ -114,6 +121,16 @@ public function getConfigs(): array return $this->configs; } + /** + * Get current http method + */ + abstract public function getHttpMethod(): string; + + /** + * Get value of an HTTP request header. Ex: "X-Forwarded-For" + */ + abstract public function getHttpRequestHeader(string $name): ?string; + /** * Returns the logger instance. * @@ -124,31 +141,47 @@ public function getLogger(): LoggerInterface return $this->logger; } + /** + * Get the value of a posted field. + */ + abstract public function getPostedVariable(string $name): ?string; + public function getRemediationEngine(): AbstractRemediation { return $this->remediationEngine; } /** - * Get the remediation for the specified IP. This method use the cache layer. - * In live mode, when no remediation was found in cache, - * the cache system will call LAPI to check if there is a decision. - * - * @param string $ip The IP to check + * Get the remediation for the specified IP. * * @return string the remediation to apply (ex: 'ban', 'captcha', 'bypass') * @throws BouncerException */ public function getRemediationForIp(string $ip): string { - return $this->capRemediationLevel($this->getRemediationEngine()->getIpRemediation($ip)); + try { + return $this->capRemediationLevel($this->getRemediationEngine()->getIpRemediation($ip)); + } catch (\Exception $e) { + throw new BouncerException($e->getMessage()); + } } + /** + * Get the current IP, even if it's the IP of a proxy + */ + abstract public function getRemoteIp(): string; + + /** + * Get current request uri + */ + abstract public function getRequestUri(): string; + /** * This method prune the cache: it removes all the expired cache items. * * @return bool If the cache has been successfully pruned or not * @throws CacheStorageException + * */ public function pruneCache(): bool { @@ -161,6 +194,7 @@ public function pruneCache(): bool * * @return array Number of deleted and new decisions * + * */ public function refreshBlocklistCache(): array { @@ -168,23 +202,222 @@ public function refreshBlocklistCache(): array } /** - * Bounce process + * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. * - * @return void + * @return bool * @throws BouncerException * @throws CacheException + * @throws InvalidArgumentException + * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException + */ + public function safelyBounce(): bool + { + $result = false; + set_error_handler(function ($errno, $errstr) { + throw new BouncerException("$errstr (Error level: $errno)"); + }); + try { + if ($this->shouldBounceCurrentIp()) { + $this->bounceCurrentIp(); + $result = true; + } + } catch (\Exception $e) { + $this->logger->error('Something went wrong during bouncing', [ + 'type' => 'EXCEPTION_WHILE_BOUNCING', + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + if (true === $this->getConfig('display_errors')) { + throw $e; + } + } + restore_error_handler(); + + return $result; + } + + /** + * If the current IP should be bounced or not, matching custom business rules. + */ + public function shouldBounceCurrentIp(): bool + { + $excludedURIs = $this->getConfig('excluded_uris') ?? []; + $uri = $this->getRequestUri(); + if ($uri && \in_array($uri, $excludedURIs)) { + $this->logger->debug('Will not bounce as URI is excluded', [ + 'type' => 'SHOULD_NOT_BOUNCE', + 'message' => 'This URI is excluded from bouncing: ' . $uri, + ]); + + return false; + } + $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getConfig('bouncing_level')); + if ($bouncingDisabled) { + $this->logger->debug('Will not bounce as bouncing is disabled', [ + 'type' => 'SHOULD_NOT_BOUNCE', + 'message' => Constants::BOUNCING_LEVEL_DISABLED, + ]); + + return false; + } + + return true; + } + + /** + * @throws BouncerException * @throws CacheStorageException + */ + protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache + { + $cacheSystem = $configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; + switch ($cacheSystem) { + case Constants::CACHE_SYSTEM_PHPFS: + $cache = new PhpFiles($configs, $logger); + break; + case Constants::CACHE_SYSTEM_MEMCACHED: + $cache = new Memcached($configs, $logger); + break; + case Constants::CACHE_SYSTEM_REDIS: + $cache = new Redis($configs, $logger); + break; + // @codeCoverageIgnoreStart + default: + throw new BouncerException("Unknown selected cache technology: $cacheSystem"); + // @codeCoverageIgnoreEnd + } + + return $cache; + } + + protected function handleClient(array $configs, LoggerInterface $logger): BouncerClient + { + $requestHandler = empty($configs['use_curl']) ? new FileGetContents($configs) : new Curl($configs); + + return new BouncerClient($configs, $requestHandler, $logger); + } + + /** + * Handle remediation for some IP. + * + * @param string $remediation + * @param string $ip + * @return void + * @throws CacheException * @throws InvalidArgumentException * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException + * @throws BouncerException + * */ - protected function bounceCurrentIp(): void + protected function handleRemediation(string $remediation, string $ip): void { - // Retrieve the current IP (even if it is a proxy IP) or a testing IP - $forcedTestIp = $this->getConfig('forced_test_ip'); - $ip = $forcedTestIp ?: $this->getRemoteIp(); - $ip = $this->handleForwardedFor($ip, $this->configs); - $remediation = $this->getRemediationForIp($ip); - $this->handleRemediation($remediation, $ip); + switch ($remediation) { + case Constants::REMEDIATION_CAPTCHA: + $this->handleCaptchaRemediation($ip); + break; + case Constants::REMEDIATION_BAN: + $this->handleBanRemediation(); + break; + case Constants::REMEDIATION_BYPASS: + default: + } + } + + /** + * @param string $redirect + * @return void + * @codeCoverageIgnore + */ + protected function redirectResponse(string $redirect): void + { + header("Location: $redirect"); + exit(0); + } + + /** + * Send HTTP response. + * @throws BouncerException + * @codeCoverageIgnore + * + */ + protected function sendResponse(string $body, int $statusCode): void + { + switch ($statusCode) { + case 401: + header('HTTP/1.0 401 Unauthorized'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + break; + case 403: + header('HTTP/1.0 403 Forbidden'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + break; + default: + throw new BouncerException("Unhandled code $statusCode"); + } + + echo $body; + + exit(0); + } + + /** + * Build a captcha couple. + * + * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" + * representing the image data + */ + private function buildCaptchaCouple(): array + { + $captchaBuilder = new CaptchaBuilder(); + + return [ + 'phrase' => $captchaBuilder->getPhrase(), + 'inlineImage' => $captchaBuilder->build()->inline(), + ]; + } + + /** + * Cap the remediation to a fixed value given by the bouncing level configuration. + * + * @param string $remediation (ex: 'ban', 'captcha', 'bypass') + * + * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') + */ + private function capRemediationLevel(string $remediation): string + { + $orderedRemediations = $this->getRemediationEngine()->getConfig('ordered_remediations') ?? []; + + $bouncingLevel = $this->getConfig('bouncing_level') ?? Constants::BOUNCING_LEVEL_NORMAL; + // Compute max remediation level + switch ($bouncingLevel) { + case Constants::BOUNCING_LEVEL_DISABLED: + $maxRemediationLevel = Constants::REMEDIATION_BYPASS; + break; + case Constants::BOUNCING_LEVEL_FLEX: + $maxRemediationLevel = Constants::REMEDIATION_CAPTCHA; + break; + case Constants::BOUNCING_LEVEL_NORMAL: + default: + $maxRemediationLevel = Constants::REMEDIATION_BAN; + break; + } + + $currentIndex = (int)array_search($remediation, $orderedRemediations); + $maxIndex = (int)array_search( + $maxRemediationLevel, + $orderedRemediations + ); + if ($currentIndex < $maxIndex) { + return $orderedRemediations[$maxIndex]; + } + + return $remediation; } /** @@ -213,11 +446,27 @@ private function checkCaptcha(string $expected, string $try, string $ip): bool return $solved; } + /** + * Configure this instance. + * + * @param array $config An array with all configuration parameters + * + */ + private function configure(array $config): void + { + // Process and validate input configuration. + $configuration = new Configuration(); + $processor = new Processor(); + $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($config)]); + } + /** * @param string $ip * @return void - * @throws CacheStorageException * @throws InvalidArgumentException + * @throws BouncerException + * @codeCoverageIgnore + * */ private function displayCaptchaWall(string $ip): void { @@ -226,7 +475,7 @@ private function displayCaptchaWall(string $ip): void ['crowdsec_captcha_resolution_failed', 'crowdsec_captcha_inline_image'], $ip ); - $body = $this->getCaptchaHtmlTemplate( + $body = $this->getCaptchaHtml( (bool)$captchaVariables['crowdsec_captcha_resolution_failed'], (string)$captchaVariables['crowdsec_captcha_inline_image'], '' @@ -241,13 +490,18 @@ private function displayCaptchaWall(string $ip): void * * @return string The HTML compiled template */ - private function getAccessForbiddenHtmlTemplate(): string + private function getBanHtml(): string { $template = new Template('ban.html.twig'); return $template->render($this->configs); } + private function getCache(): AbstractCache + { + return $this->getRemediationEngine()->getCacheStorage(); + } + /** * Returns a default "CrowdSec Captcha (401)" HTML template. * @@ -256,7 +510,7 @@ private function getAccessForbiddenHtmlTemplate(): string * @param string $captchaResolutionFormUrl * @return string */ - private function getCaptchaHtmlTemplate( + private function getCaptchaHtml( bool $error, string $captchaImageSrc, string $captchaResolutionFormUrl @@ -284,48 +538,29 @@ private function getTrustForwardedIpBoundsList(): array /** * @return void * + * @throws BouncerException + * @throws BouncerException + * @codeCoverageIgnore + * */ private function handleBanRemediation(): void { - $body = $this->getAccessForbiddenHtmlTemplate(); + $body = $this->getBanHtml(); $this->sendResponse($body, 403); } - /** - * @throws BouncerException - * @throws CacheStorageException - */ - protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache - { - $cacheSystem = $configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; - switch ($cacheSystem) { - case Constants::CACHE_SYSTEM_PHPFS: - $cache = new PhpFiles($configs, $logger); - break; - case Constants::CACHE_SYSTEM_MEMCACHED: - $cache = new Memcached($configs, $logger); - break; - case Constants::CACHE_SYSTEM_REDIS: - $cache = new Redis($configs, $logger); - break; - default: - throw new BouncerException("Unknown selected cache technology: $cacheSystem"); - } - - return $cache; - } - /** * @param string $ip * * @return void * + * @throws BouncerException * @throws CacheException - * @throws CacheStorageException * @throws InvalidArgumentException * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException + * */ - private function handleCaptchaRemediation(string $ip) + private function handleCaptchaRemediation(string $ip): void { // Check captcha resolution form $this->handleCaptchaResolutionForm($ip); @@ -338,24 +573,7 @@ private function handleCaptchaRemediation(string $ip) if (null === $cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved']) { // Set up the first captcha remediation. $mustResolve = true; - $captchaCouple = $this->buildCaptchaCouple(); - $captchaVariables = [ - 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], - 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], - 'crowdsec_captcha_has_to_be_resolved' => true, - 'crowdsec_captcha_resolution_failed' => false, - 'crowdsec_captcha_resolution_redirect' => 'POST' === $this->getHttpMethod() && - !empty($_SERVER['HTTP_REFERER']) - ? $_SERVER['HTTP_REFERER'] : '/', - ]; - $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; - $this->getCache()->setIpVariables( - Constants::CACHE_TAG_CAPTCHA, - $captchaVariables, - $ip, - $duration, - Constants::CACHE_TAG_CAPTCHA - ); + $this->initCaptchaResolution($ip); } // Display captcha page if this is required. @@ -369,10 +587,10 @@ private function handleCaptchaRemediation(string $ip) * @return void * * @throws CacheException - * @throws CacheStorageException * @throws InvalidArgumentException * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException * @SuppressWarnings(PHPMD.ElseExpression) + * */ private function handleCaptchaResolutionForm(string $ip): void { @@ -385,7 +603,7 @@ private function handleCaptchaResolutionForm(string $ip): void ], $ip ); - if ($this->shouldEarlyReturn($cachedCaptchaVariables, $ip)) { + if ($this->shouldNotCheckResolution($cachedCaptchaVariables) || $this->refreshCaptcha($ip)) { return; } @@ -424,8 +642,7 @@ private function handleCaptchaResolutionForm(string $ip): void Constants::CACHE_TAG_CAPTCHA ); $redirect = $cachedCaptchaVariables['crowdsec_captcha_resolution_redirect'] ?? '/'; - header("Location: $redirect"); - exit(0); + $this->redirectResponse($redirect); } else { // The user failed to resolve the captcha. $this->getCache()->setIpVariables( @@ -439,13 +656,6 @@ private function handleCaptchaResolutionForm(string $ip): void } } - protected function handleClient(array $configs, LoggerInterface $logger): BouncerClient - { - $requestHandler = empty($configs['use_curl']) ? new FileGetContents($configs) : new Curl($configs); - - return new BouncerClient($configs, $requestHandler, $logger); - } - /** * Handle X-Forwarded-For HTTP header to retrieve the IP to bounce * @@ -471,15 +681,27 @@ private function handleForwardedFor(string $ip, array $configs): string ]); } else { $forwardedIp = (string)$configs['forced_test_forwarded_ip']; + $this->logger->debug('X-Forwarded-for usage is forced', [ + 'type' => 'FORCED_X_FORWARDED_FOR_USAGE', + 'original_ip' => $ip, + 'x_forwarded_for_ip' => $forwardedIp, + ]); } - if (is_string($forwardedIp) && $this->shouldTrustXforwardedFor($ip)) { - $ip = $forwardedIp; - } else { + if (is_string($forwardedIp)) { + if ($this->shouldTrustXforwardedFor($ip)) { + $this->logger->debug('Detected IP is allowed for X-Forwarded-for usage', [ + 'type' => 'AUTHORIZED_X_FORWARDED_FOR_USAGE', + 'original_ip' => $ip, + 'x_forwarded_for_ip' => $forwardedIp, + ]); + + return $forwardedIp; + } $this->logger->warning('Detected IP is not allowed for X-Forwarded-for usage', [ 'type' => 'NON_AUTHORIZED_X_FORWARDED_FOR_USAGE', 'original_ip' => $ip, - 'x_forwarded_for_ip' => is_string($forwardedIp) ? $forwardedIp : 'type not as expected', + 'x_forwarded_for_ip' => $forwardedIp, ]); } @@ -487,132 +709,42 @@ private function handleForwardedFor(string $ip, array $configs): string } /** - * Handle remediation for some IP. - * - * @param string $remediation - * @param string $ip - * @return void * @throws CacheException - * @throws CacheStorageException * @throws InvalidArgumentException * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException - */ - private function handleRemediation(string $remediation, string $ip) - { - switch ($remediation) { - case Constants::REMEDIATION_CAPTCHA: - $this->handleCaptchaRemediation($ip); - break; - case Constants::REMEDIATION_BAN: - $this->handleBanRemediation(); - break; - case Constants::REMEDIATION_BYPASS: - default: - } - } - - private function shouldTrustXforwardedFor(string $ip): bool - { - $parsedAddress = Factory::parseAddressString($ip, 3); - if (null === $parsedAddress) { - $this->logger->warning('IP is invalid', [ - 'type' => 'INVALID_INPUT_IP', - 'ip' => $ip, - ]); - - return false; - } - $comparableAddress = $parsedAddress->getComparableString(); - - foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) { - if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) { - return true; - } - } - - return false; - } - - /** - * Cap the remediation to a fixed value given by the bouncing level configuration. * - * @param string $remediation (ex: 'ban', 'captcha', 'bypass') - * - * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') - * @throws BouncerException */ - private function capRemediationLevel(string $remediation): string + private function initCaptchaResolution(string $ip): void { - $orderedRemediations = $this->getRemediationEngine()->getConfig('ordered_remediations') ?? []; - - $bouncingLevel = $this->getConfig('bouncing_level') ?? Constants::BOUNCING_LEVEL_NORMAL; - // Compute max remediation level - switch ($bouncingLevel) { - case Constants::BOUNCING_LEVEL_DISABLED: - $maxRemediationLevel = Constants::REMEDIATION_BYPASS; - break; - case Constants::BOUNCING_LEVEL_FLEX: - $maxRemediationLevel = Constants::REMEDIATION_CAPTCHA; - break; - case Constants::BOUNCING_LEVEL_NORMAL: - $maxRemediationLevel = Constants::REMEDIATION_BAN; - break; - default: - throw new BouncerException("Unknown $bouncingLevel"); - } - - $currentIndex = (int)array_search($remediation, $orderedRemediations); - $maxIndex = (int)array_search( - $maxRemediationLevel, - $orderedRemediations + $captchaCouple = $this->buildCaptchaCouple(); + $referer = $this->getHttpRequestHeader('REFERER'); + $captchaVariables = [ + 'crowdsec_captcha_phrase_to_guess' => $captchaCouple['phrase'], + 'crowdsec_captcha_inline_image' => $captchaCouple['inlineImage'], + 'crowdsec_captcha_has_to_be_resolved' => true, + 'crowdsec_captcha_resolution_failed' => false, + 'crowdsec_captcha_resolution_redirect' => 'POST' === $this->getHttpMethod() && !empty($referer) + ? $referer : '/', + ]; + $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; + $this->getCache()->setIpVariables( + Constants::CACHE_TAG_CAPTCHA, + $captchaVariables, + $ip, + $duration, + Constants::CACHE_TAG_CAPTCHA ); - if ($currentIndex < $maxIndex) { - return $orderedRemediations[$maxIndex]; - } - - return $remediation; } /** - * Configure this instance. - * - * @param array $config An array with all configuration parameters - * - */ - private function configure(array $config): void - { - // Process and validate input configuration. - $configuration = new Configuration(); - $processor = new Processor(); - $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($config)]); - } - - private function getCache(): AbstractCache - { - return $this->getRemediationEngine()->getCacheStorage(); - } - - /** - * @param array $cachedCaptchaVariables - * @param string $ip - * @return bool - * * @throws CacheException - * @throws CacheStorageException * @throws InvalidArgumentException * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException + * */ - private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): bool + private function refreshCaptcha(string $ip): bool { - $result = false; - if (\in_array($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'], [null, false])) { - // Early return if no captcha has to be resolved. - $result = true; - } elseif ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) { - // Early return if no form captcha form has been filled. - $result = true; - } elseif (null !== $this->getPostedVariable('refresh') && (int)$this->getPostedVariable('refresh')) { - // Handle image refresh. + if (null !== $this->getPostedVariable('refresh') && (int)$this->getPostedVariable('refresh')) { // Generate new captcha image for the user $captchaCouple = $this->buildCaptchaCouple(); $captchaVariables = [ @@ -629,9 +761,52 @@ private function shouldEarlyReturn(array $cachedCaptchaVariables, string $ip): b Constants::CACHE_TAG_CAPTCHA ); + return true; + } + + return false; + } + + /** + * Check + * + * @param array $cachedCaptchaVariables + * @return bool + * + */ + private function shouldNotCheckResolution(array $cachedCaptchaVariables): bool + { + $result = false; + if (\in_array($cachedCaptchaVariables['crowdsec_captcha_has_to_be_resolved'], [null, false])) { + // Check not needed if 'crowdsec_captcha_has_to_be_resolved' cached flag has not been saved + $result = true; + } elseif ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) { + // Check not needed if no form captcha form has been filled. $result = true; } return $result; } + + private function shouldTrustXforwardedFor(string $ip): bool + { + $parsedAddress = Factory::parseAddressString($ip, 3); + if (null === $parsedAddress) { + $this->logger->warning('IP is invalid', [ + 'type' => 'INVALID_INPUT_IP', + 'ip' => $ip, + ]); + + return false; + } + $comparableAddress = $parsedAddress->getComparableString(); + + foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) { + if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) { + return true; + } + } + + return false; + } } diff --git a/src/BouncerInterface.php b/src/BouncerInterface.php deleted file mode 100644 index 0280e67..0000000 --- a/src/BouncerInterface.php +++ /dev/null @@ -1,53 +0,0 @@ -children() ->enumNode('bouncing_level') - ->values( - [ - Constants::BOUNCING_LEVEL_DISABLED, - Constants::BOUNCING_LEVEL_NORMAL, - Constants::BOUNCING_LEVEL_FLEX - ] - ) - ->defaultValue(Constants::BOUNCING_LEVEL_NORMAL) + ->values( + [ + Constants::BOUNCING_LEVEL_DISABLED, + Constants::BOUNCING_LEVEL_NORMAL, + Constants::BOUNCING_LEVEL_FLEX + ] + ) + ->defaultValue(Constants::BOUNCING_LEVEL_NORMAL) ->end() ->arrayNode('trust_ip_forward_array') - ->arrayPrototype() - ->scalarPrototype()->end() - ->end() + ->arrayPrototype() + ->scalarPrototype()->end() + ->end() ->end() ->arrayNode('excluded_uris') - ->scalarPrototype()->end() + ->scalarPrototype()->end() ->end() - ->end(); + ->end(); } /** diff --git a/src/Fixes/Gregwar/Captcha/CaptchaBuilder.php b/src/Fixes/Gregwar/Captcha/CaptchaBuilder.php index eccd60b..ce369a1 100644 --- a/src/Fixes/Gregwar/Captcha/CaptchaBuilder.php +++ b/src/Fixes/Gregwar/Captcha/CaptchaBuilder.php @@ -13,6 +13,7 @@ * @see https://github.com/crowdsecurity/php-cs-bouncer/issues/62 and * @see https://github.com/Gregwar/Captcha/pull/101/files * @SuppressWarnings(PHPMD.ElseExpression) + * @codeCoverageIgnore * */ class CaptchaBuilder extends GregwarCaptchaBuilder diff --git a/src/StandaloneBouncer.php b/src/StandaloneBouncer.php index edebce0..f8e0048 100644 --- a/src/StandaloneBouncer.php +++ b/src/StandaloneBouncer.php @@ -5,12 +5,9 @@ namespace CrowdSecBouncer; use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; -use Exception; -use IPLib\Factory; use CrowdSec\RemediationEngine\LapiRemediation; use CrowdSec\RemediationEngine\Logger\FileLog; -use Psr\Cache\CacheException; -use Psr\Cache\InvalidArgumentException; +use IPLib\Factory; use Psr\Log\LoggerInterface; /** @@ -84,104 +81,20 @@ public function getRemoteIp(): string } /** - * If there is any technical problem while bouncing, don't block the user. Bypass bouncing and log the error. - * - * @return bool - * @throws BouncerException - * @throws CacheException - * @throws CacheStorageException - * @throws InvalidArgumentException - * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException + * The current URI. */ - public function safelyBounce(): bool + public function getRequestUri(): string { - $result = false; - set_error_handler(function ($errno, $errstr) { - throw new BouncerException("$errstr (Error level: $errno)"); - }); - try { - if ($this->shouldBounceCurrentIp()) { - $this->bounceCurrentIp(); - $result = true; - } - } catch (Exception $e) { - $this->logger->error('Something went wrong during bouncing', [ - 'type' => 'EXCEPTION_WHILE_BOUNCING', - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - if (true === $this->getConfig('display_errors')) { - throw $e; - } - } - restore_error_handler(); - - return $result; + return $_SERVER['REQUEST_URI'] ?? ""; } /** - * Send HTTP response. - * @throws BouncerException - */ - public function sendResponse(?string $body, int $statusCode = 200): void - { - switch ($statusCode) { - case 200: - header('HTTP/1.0 200 OK'); - break; - case 401: - header('HTTP/1.0 401 Unauthorized'); - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); - break; - case 403: - header('HTTP/1.0 403 Forbidden'); - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); - break; - default: - throw new BouncerException("Unhandled code $statusCode"); - } - if (null !== $body) { - echo $body; - } - exit(); - } - - /** - * If the current IP should be bounced or not, matching custom business rules. - */ - public function shouldBounceCurrentIp(): bool - { - $excludedURIs = $this->getConfig('excluded_uris') ?? []; - if (isset($_SERVER['REQUEST_URI']) && \in_array($_SERVER['REQUEST_URI'], $excludedURIs)) { - $this->logger->debug('Will not bounce as URI is excluded', [ - 'type' => 'SHOULD_NOT_BOUNCE', - 'message' => 'This URI is excluded from bouncing: ' . $_SERVER['REQUEST_URI'], - ]); - - return false; - } - $bouncingDisabled = (Constants::BOUNCING_LEVEL_DISABLED === $this->getConfig('bouncing_level')); - if ($bouncingDisabled) { - $this->logger->debug('Will not bounce as bouncing is disabled', [ - 'type' => 'SHOULD_NOT_BOUNCE', - 'message' => Constants::BOUNCING_LEVEL_DISABLED, - ]); - - return false; - } - - return true; - } - - /** - * Initialize the bouncer. + * The Standalone bouncer "trust_ip_forward_array" config accepts an array of IPs. + * This method will return array of comparable IPs array * + * @param array $configs // ['1.2.3.4'] + * @return array // [['001.002.003.004', '001.002.003.004']] + * @throws BouncerException */ private function handleTrustedIpsConfig(array $configs): array { @@ -190,14 +103,13 @@ private function handleTrustedIpsConfig(array $configs): array $forwardConfigs = $configs['trust_ip_forward_array']; $finalForwardConfigs = []; foreach ($forwardConfigs as $forwardConfig) { - if (\is_string($forwardConfig)) { - $parsedString = Factory::parseAddressString($forwardConfig, 3); - if (!empty($parsedString)) { - $comparableValue = $parsedString->getComparableString(); - $finalForwardConfigs[] = [$comparableValue, $comparableValue]; - } - } elseif (\is_array($forwardConfig)) { - $finalForwardConfigs[] = $forwardConfig; + if (!\is_string($forwardConfig)) { + throw new BouncerException("'trust_ip_forward_array' config must be an array of string"); + } + $parsedString = Factory::parseAddressString($forwardConfig, 3); + if (!empty($parsedString)) { + $comparableValue = $parsedString->getComparableString(); + $finalForwardConfigs[] = [$comparableValue, $comparableValue]; } } $configs['trust_ip_forward_array'] = $finalForwardConfigs; diff --git a/src/templates/captcha.html.twig b/src/templates/captcha.html.twig index 7858615..9d30bb1 100644 --- a/src/templates/captcha.html.twig +++ b/src/templates/captcha.html.twig @@ -19,7 +19,7 @@ captcha

    {{ config.text.captcha_wall.refresh_image_link |e }}

    + onclick="newImageForCrowdSec()">{{ config.text.captcha_wall.refresh_image_link |e }}

    diff --git a/src/templates/partials/captcha-js.html.twig b/src/templates/partials/captcha-js.html.twig index 574a669..33535c4 100644 --- a/src/templates/partials/captcha-js.html.twig +++ b/src/templates/partials/captcha-js.html.twig @@ -1,6 +1,6 @@