diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 3d47308..d47e737 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -49,13 +49,13 @@ jobs: - name: Add-ons install run: ddev get julienloizelet/ddev-tools - - name: Add Redis, Memcached and X-Debug - if: ${{ matrix.php-version == '8.3' }} - run: | - cp .ddev/okaeli-add-on/common/custom_files/config.php83missing.yaml .ddev/config.php83missing.yaml - - - name: Start DDEV with PHP ${{ matrix.php-version }} - run: ddev start + - name: Start DDEV + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + shell: bash + command: ddev start - name: Some DEBUG information run: | diff --git a/.github/workflows/markdown.yml b/.github/workflows/doc-links.yml similarity index 72% rename from .github/workflows/markdown.yml rename to .github/workflows/doc-links.yml index 5e84b68..b559187 100644 --- a/.github/workflows/markdown.yml +++ b/.github/workflows/doc-links.yml @@ -1,12 +1,16 @@ on: workflow_dispatch: - -name: Markdown files test and update + push: + branches: + - main + pull_request: + branches: + - main permissions: - contents: write - pull-requests: write + contents: read +name: Documentation links jobs: markdown-test-and-update: name: Markdown files test and update @@ -34,12 +38,3 @@ jobs: cd extension awesome_bot --files README.md --allow-dupe --allow 401 --skip-save-results --white-list ddev.site --base-url http://localhost:8080/ awesome_bot docs/*.md --skip-save-results --allow-dupe --allow 401 --white-list crowdsec.net/v2,ddev.site,https://crowdsec,your-crowdsec-lapi-url --base-url http://localhost:8080/docs/ - - - name: Generate table of contents - uses: technote-space/toc-generator@v4 - with: - MAX_HEADER_LEVEL: 5 - COMMIT_NAME: CrowdSec Dev Bot - TARGET_PATHS: 'docs/*.md' - CHECK_ONLY_DEFAULT_BRANCH: true - CREATE_PR: true diff --git a/.github/workflows/unit-and-integration-test.yml b/.github/workflows/unit-and-integration-test.yml index 3bdd4af..25cc694 100644 --- a/.github/workflows/unit-and-integration-test.yml +++ b/.github/workflows/unit-and-integration-test.yml @@ -57,18 +57,18 @@ jobs: ddev get julienloizelet/ddev-tools ddev get julienloizelet/ddev-crowdsec-php - - name: Add Redis, Memcached and X-Debug - if: ${{ matrix.php-version == '8.3' }} - run: | - cp .ddev/okaeli-add-on/common/custom_files/config.php83missing.yaml .ddev/config.php83missing.yaml - - name: Prepare for TLS tests run: | mkdir ${{ github.workspace }}/cfssl cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl - - name: Start DDEV with PHP ${{ matrix.php-version }} - run: ddev start + - name: Start DDEV + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 3 + shell: bash + command: ddev start - name: Some DEBUG information run: | @@ -104,10 +104,19 @@ jobs: if: | github.event.inputs.integration_tests == 'true' || github.event_name == 'push' - 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 + run: ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout ./${{env.EXTENSION_PATH}}/tests/Integration - name: Run Integration tests (with TLS) if: | github.event.inputs.integration_tests == 'true' || github.event_name == 'push' - run: ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_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 + run: ddev exec AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout ./${{env.EXTENSION_PATH}}/tests/Integration + + - name: Run Integration tests with timeout + if: | + github.event.inputs.integration_tests == 'true' || + github.event_name == 'push' + run: | + ddev exec -s crowdsec apk add iproute2 + ddev exec -s crowdsec tc qdisc add dev eth0 root netem delay 500ms + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --group timeout ./${{env.EXTENSION_PATH}}/tests/Integration diff --git a/.gitignore b/.gitignore index 011e0b9..79f6bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Composer vendor composer.lock +composer-dev* # Systems .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index a88655d..644b07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The [public API](https://semver.org/spec/v2.0.0.html#spec-item-1) of this library consists of all public or protected methods, properties and constants belonging to the `src` folder. +As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com/doc/current/contributing/code/bc.html#working-on-symfony-code) when deciding whether a change is a breaking change or not. + +--- + +## [3.3.0](https://github.com/crowdsecurity/php-lapi-client/releases/tag/v3.3.0) - 2024-??-?? +[_Compare with previous release_](https://github.com/crowdsecurity/php-lapi-client/compare/v3.2.0...HEAD) + +### Added + +- Add `getAppSecDecision` method to `Bouncer` class +- Add `appsec_url`, 'appsec_timeout' and `appsec_connect_timeout` configuration + +### Changed + +- Throws a `CrowdSec\LapiClient\TimeoutException` when a timeout is detected during client calls + + + --- ## [3.2.0](https://github.com/crowdsecurity/php-lapi-client/releases/tag/v3.2.0) - 2023-12-07 diff --git a/composer.json b/composer.json index 75edca5..4dcf752 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "client", "curl", "lapi", + "appsec", "bouncer" ], "authors": [ @@ -36,7 +37,7 @@ }, "require": { "php": "^7.2.5 || ^8.0", - "crowdsec/common": "^2.2.0", + "crowdsec/common": "^2.3.0", "ext-json": "*", "symfony/config": "^4.4.44 || ^5.4.11 || ^6.0.11", "monolog/monolog": "^1.17 || ^2.1" diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index c277b30..c4dfd8a 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -17,14 +17,10 @@ - [Unit test](#unit-test) - [Integration test](#integration-test) - [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) + - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) - [Commit message](#commit-message) - [Allowed message `type` values](#allowed-message-type-values) +- [Update documentation table of contents](#update-documentation-table-of-contents) - [Release process](#release-process) @@ -136,7 +132,8 @@ Finally, run In order to launch integration tests, we have to set some environment variables: ```bash -ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox +ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 +LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox --exclude-group timeout ``` `` should have been created and retrieved before this test by running `ddev create-bouncer`. @@ -144,7 +141,8 @@ ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL= If you need to test with a TLS authentication, you should launch: ```bash -ddev exec BOUNCER_TLS_PATH=/var/www/html/cfssl AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox +ddev exec BOUNCER_TLS_PATH=/var/www/html/cfssl BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl +APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox --exclude-group timeout ``` #### Coding standards @@ -165,7 +163,6 @@ With ddev, you can do the following: ```bash ddev phpcsfixer my-code/lapi-client/tools/coding-standards/php-cs-fixer ../ - ``` ##### PHPSTAN @@ -235,6 +232,33 @@ If you want to generate a text report in the same folder: ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./my-code/lapi-client/tools/coding-standards/phpunit/code-coverage/report.txt ``` +#### Testing timeout in the CrowdSec container + +If you need to test a timeout, you can use the following command: + +Install `iproute2` +```bash +ddev exec -s crowdsec apk add iproute2 +``` +Add the delay you want: +```bash +ddev exec -s crowdsec tc qdisc add dev eth0 root netem delay 500ms +``` + +To remove the delay: +```bash +ddev exec -s crowdsec tc qdisc del dev eth0 root netem +``` + +To execute integration tests with a timeout, you can run: + +```bash +ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 +LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox --group timeout +``` + + + ## Commit message In order to have an explicit commit history, we are using some commits message convention with the following format: @@ -270,6 +294,24 @@ chmod +x .git/hooks/commit-msg - style (formatting; no production code change) - test (adding missing tests, refactoring tests; no production code change) + +## Update documentation table of contents + +To update the table of contents in the documentation, you can use [the `doctoc` tool](https://github.com/thlorenz/doctoc). + +First, install it: + +```bash +npm install -g doctoc +``` + +Then, run it in the documentation folder: + +```bash +doctoc docs/* --maxlevel 4 +``` + + ## Release process We are using [semantic versioning](https://semver.org/) to determine a version number. To verify the current tag, diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2c37ed1..aa851f1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -14,10 +14,9 @@ - [Installation](#installation) - [Bouncer client instantiation](#bouncer-client-instantiation) - [LAPI calls](#lapi-calls) - - [Get Decisions stream list](#get-decisions-stream-list) - - [Get filtered Decisions](#get-filtered-decisions) - [Bouncer client configurations](#bouncer-client-configurations) - [LAPI url](#lapi-url) + - [AppSec url](#appsec-url) - [Authorization type for connection](#authorization-type-for-connection) - [Settings for Api key authorization](#settings-for-api-key-authorization) - [Api key](#api-key) @@ -26,7 +25,12 @@ - [Bouncer key path](#bouncer-key-path) - [Peer verification](#peer-verification) - [CA certificate path](#ca-certificate-path) - - [LAPI timeout](#lapi-timeout) + - [Settings for LAPI timeout](#settings-for-lapi-timeout) + - [LAPI timeout](#lapi-timeout) + - [LAPI connect timeout](#lapi-connect-timeout) + - [Settings for AppSec timeout](#settings-for-appsec-timeout) + - [AppSec timeout](#appsec-timeout) + - [AppSec connect timeout](#appsec-connect-timeout) - [User Agent suffix](#user-agent-suffix) - [User Agent version](#user-agent-version) - [Override the curl request handler](#override-the-curl-request-handler) @@ -36,9 +40,12 @@ - [Get decisions stream](#get-decisions-stream) - [Command usage](#command-usage) - [Example usage](#example-usage) - - [Get filtered decisions](#get-filtered-decisions-1) + - [Get filtered decisions](#get-filtered-decisions) - [Command usage](#command-usage-1) - [Example](#example) + - [Get AppSec decision](#get-appsec-decision) + - [Command usage](#command-usage-2) + - [Example](#example-1) @@ -52,6 +59,7 @@ This client allows you to interact with the CrowdSec Local API (LAPI). - CrowdSec LAPI Bouncer available endpoints - Retrieve decisions stream list - Retrieve decisions for some filter + - Retrieve AppSec decision - Overridable request handler (`curl` by default, `file_get_contents` also available) @@ -60,6 +68,7 @@ This client allows you to interact with the CrowdSec Local API (LAPI). ### Installation First, install CrowdSec LAPI PHP Client via the [composer](https://getcomposer.org/) package manager: + ```bash composer require crowdsec/lapi-client ``` @@ -90,6 +99,7 @@ use Crowdsec\LapiClient\Storage\FileStorage; $configs = [ 'auth_type' => 'api_key', 'api_url' => 'https://your-crowdsec-lapi-url:8080', + 'appsec_url' => 'https://your-crowdsec-app-sec-url:7422', 'api_key' => '**************************', ]; $client = new Bouncer($configs); @@ -127,6 +137,20 @@ $client->getFilteredDecisions($filter); The `$filter` parameter is an array. Please see the [CrowdSec LAPI documentation](https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI#/bouncers/getDecisions) for more details about available filters (scope, value, type, etc.). +##### Get AppSec decision + +To retrieve an AppSec decision, you can do the following call: + +```php +$client->getAppSecDecision($headers, $rawBody); +``` + +The `$headers` parameter is an array containing the headers of the forwarded request and some required headers for the AppSec decision. + +The `$rawBody` parameter is optional and must be used if the forwarded request contains a body. It must be a string. + +Please see the [CrowdSec AppSec documentation](https://docs.crowdsec.net/docs/appsec/intro) for more details. + ## Bouncer client configurations @@ -145,6 +169,18 @@ $configs = [ Define the URL to your LAPI server, default to `http://localhost:8080`. +### AppSec url + +```php +$configs = [ + ... + 'appsec_url' => 'http://your-crowdsec-app-sec-url:7422' + ... +]; +``` + +Define the AppSec URL to your LAPI server, default to `http://localhost:7422`. + ### Authorization type for connection ```php @@ -249,8 +285,9 @@ Absolute path to the CA used to process peer verification. Only required if you choose `tls` as `auth_type` and `tls_verify_peer` is `true`. +### Settings for LAPI timeout -### LAPI timeout +#### LAPI timeout ```php $configs = [ @@ -266,6 +303,59 @@ This is the maximum number of seconds allowed to execute a LAPI request. It must be an integer. If you don't set any value, default value is 120. If you set a negative value, timeout is unlimited. +#### LAPI connect timeout + +```php +$configs = [ + ... + 'api_connect_timeout' => 5 + ... +]; +``` + +This setting is not required and will only be used for the `Curl` request handler. + +This is the maximum number of seconds allowed to connect to a LAPI server. + +It must be an integer. If you don't set any value, default value is 300. If you set a negative value, timeout is unlimited. + + +### Settings for AppSec timeout + +#### AppSec timeout + +```php +$configs = [ + ... + 'appsec_timeout_ms' => 300 + ... +]; +``` + +This setting is not required. + +This is the maximum number of milliseconds allowed to execute an AppSec request. + +It must be an integer. If you don't set any value, default value is 400. If you set a negative value, timeout is unlimited. + + +#### AppSec connect timeout + +```php +$configs = [ + ... + 'appsec_connect_timeout_ms' => 100 + ... +]; +``` + +This setting is not required and will only be used for the `Curl` request handler. + +This is the maximum number of milliseconds allowed to connect to an AppSec server. + +It must be an integer. If you don't set any value, default value is 150. If you set a negative value, timeout is unlimited. + + ### User Agent suffix ```php @@ -401,7 +491,7 @@ php tests/scripts/bouncer/request-handler-override/decisions-stream.php 1 '{"sco #### Command usage -```php +```bash php tests/scripts/bouncer/decisions-filter.php ``` @@ -417,3 +507,17 @@ Or, with the `file_get_contents` handler: php tests/scripts/bouncer/request-handler-override/decisions-filter.php '{"scopes":"Ip"}' 92d3de1dde6d354b771d63035cf5ef83 https://crowdsec:8080 ``` +### Get AppSec decision + +#### Command usage + +```bash +php tests/scripts/bouncer/appsec-decision.php +[] +``` + +#### Example + +```bash +php tests/scripts/bouncer/appsec-decision.php 'KWurslwIaE2aZSZjYU9mQAWTFb6AHiPTFNTsYTZvoAU' '{"X-Crowdsec-Appsec-Ip":"1.2.3.4","X-Crowdsec-Appsec-Uri":"/login","X-Crowdsec-Appsec-Host":"example.com","X-Crowdsec-Appsec-Verb":"POST","X-Crowdsec-Appsec-User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Firefox/68.0"}' http://crowdsec:7422 'class.module.classLoader.resources.' +``` diff --git a/src/Bouncer.php b/src/Bouncer.php index 8f5389a..b6399a5 100644 --- a/src/Bouncer.php +++ b/src/Bouncer.php @@ -7,6 +7,7 @@ use CrowdSec\Common\Client\AbstractClient; use CrowdSec\Common\Client\ClientException as CommonClientException; use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; +use CrowdSec\Common\Client\TimeoutException as CommonTimeoutException; use Psr\Log\LoggerInterface; use Symfony\Component\Config\Definition\Processor; @@ -33,17 +34,35 @@ class Bouncer extends AbstractClient public function __construct( array $configs, - RequestHandlerInterface $requestHandler = null, - LoggerInterface $logger = null + ?RequestHandlerInterface $requestHandler = null, + ?LoggerInterface $logger = null ) { $this->configure($configs); - $this->headers = ['User-Agent' => $this->formatUserAgent($this->configs)]; + $this->headers = [Constants::HEADER_LAPI_USER_AGENT => $this->formatUserAgent($this->configs)]; if (!empty($this->configs['api_key'])) { - $this->headers['X-Api-Key'] = $this->configs['api_key']; + $this->headers[Constants::HEADER_LAPI_API_KEY] = $this->configs['api_key']; } parent::__construct($this->configs, $requestHandler, $logger); } + /** + * Process a call to AppSec component. + * + * @see https://docs.crowdsec.net/docs/appsec/protocol + * + * @throws ClientException + */ + public function getAppSecDecision(array $headers, string $rawBody = ''): array + { + $method = $rawBody ? 'POST' : 'GET'; + + return $this->manageAppSecRequest( + $method, + $headers, + $rawBody + ); + } + /** * Process a decisions call to LAPI with some filter(s). * @@ -103,7 +122,33 @@ private function formatUserAgent(array $configs = []): string } /** - * Make a request. + * Make a request to the AppSec component of LAPI. + * + * @throws ClientException + */ + private function manageAppSecRequest( + string $method, + array $headers = [], + string $rawBody = '' + ): array { + try { + $this->logger->debug('Now processing a bouncer AppSec request', [ + 'type' => 'BOUNCER_CLIENT_APPSEC_REQUEST', + 'method' => $method, + 'rawBody' => $rawBody, + 'headers' => $headers, + ]); + + return $this->requestAppSec($method, $headers, $rawBody); + } catch (CommonTimeoutException $e) { + throw new TimeoutException($e->getMessage(), $e->getCode(), $e); + } catch (CommonClientException $e) { + throw new ClientException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Make a request to LAPI. * * @throws ClientException */ @@ -121,6 +166,8 @@ private function manageRequest( ]); return $this->request($method, $endpoint, $parameters, $this->headers); + } catch (CommonTimeoutException $e) { + throw new TimeoutException($e->getMessage(), $e->getCode(), $e); } catch (CommonClientException $e) { throw new ClientException($e->getMessage(), $e->getCode(), $e); } diff --git a/src/Configuration.php b/src/Configuration.php index 8ca617e..d8bc5de 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -26,6 +26,7 @@ class Configuration extends AbstractConfiguration 'user_agent_suffix', 'user_agent_version', 'api_url', + 'appsec_url', 'auth_type', 'api_key', 'tls_cert_path', @@ -34,6 +35,8 @@ class Configuration extends AbstractConfiguration 'tls_verify_peer', 'api_timeout', 'api_connect_timeout', + 'appsec_timeout_ms', + 'appsec_connect_timeout_ms', ]; /** @@ -70,6 +73,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ; $this->addConnectionNodes($rootNode); + $this->addAppSecNodes($rootNode); $this->validate($rootNode); return $treeBuilder; @@ -113,6 +117,24 @@ private function addConnectionNodes($rootNode) ->end(); } + /** + * AppSec settings. + * + * @param NodeDefinition|ArrayNodeDefinition $rootNode + * + * @return void + * + * @throws \InvalidArgumentException + */ + private function addAppSecNodes($rootNode) + { + $rootNode->children() + ->scalarNode('appsec_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_APPSEC_URL)->end() + ->integerNode('appsec_timeout_ms')->defaultValue(Constants::APPSEC_TIMEOUT_MS)->end() + ->integerNode('appsec_connect_timeout_ms')->defaultValue(Constants::APPSEC_CONNECT_TIMEOUT_MS)->end() + ->end(); + } + /** * Conditional validation. * diff --git a/src/Constants.php b/src/Constants.php index 06c04e9..3020e81 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -26,6 +26,10 @@ class Constants extends CommonConstants * @var string The decisions stream endpoint */ public const DECISIONS_STREAM_ENDPOINT = '/v1/decisions/stream'; + /** + * @var string The Default URL of the CrowdSec AppSec endpoint + */ + public const DEFAULT_APPSEC_URL = 'http://localhost:7422'; /** * @var string The Default URL of the CrowdSec LAPI */ diff --git a/src/TimeoutException.php b/src/TimeoutException.php new file mode 100644 index 0000000..15533e7 --- /dev/null +++ b/src/TimeoutException.php @@ -0,0 +1,19 @@ + $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, 'api_key' => getenv('BOUNCER_KEY'), 'api_url' => getenv('LAPI_URL'), + 'appsec_url' => getenv('APPSEC_URL'), 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, ]; if ($this->useTls) { @@ -175,6 +178,104 @@ public function testFilteredDecisions($requestHandler) $this->assertCount(0, $response, '0 decision after delete for specified IP'); } + /** + * @dataProvider requestHandlerProvider + */ + public function testAppSecDecision($requestHandler) + { + $bouncerKey = getenv('BOUNCER_KEY'); + if (!$bouncerKey) { + $this->fail('BOUNCER_KEY is not set'); + } + if ('FileGetContents' === $requestHandler) { + $client = new Bouncer($this->configs, new FileGetContents($this->configs)); + } else { + // Curl by default + $client = new Bouncer($this->configs); + } + if ($this->useTls) { + $this->assertEquals(Constants::AUTH_TLS, $this->configs['auth_type']); + } else { + $this->assertEquals(Constants::AUTH_KEY, $this->configs['auth_type']); + } + $this->checkRequestHandler($client, $requestHandler); + + $headers = [ + 'X-Crowdsec-Appsec-Api-Key' => $bouncerKey, + 'X-Crowdsec-Appsec-Ip' => TestConstants::BAD_IP, + 'X-Crowdsec-Appsec-Host' => 'example.com', + 'X-Crowdsec-Appsec-User-Agent' => 'Mozilla/5.0', + 'X-Crowdsec-Appsec-Verb' => 'GET', + 'X-Crowdsec-Appsec-Uri' => '/login', + ]; + + // Test 1: clean GET request + $response = $client->getAppSecDecision($headers); + $this->assertEquals(['action' => 'allow', 'http_status' => 200], $response, 'Should receive 200'); + // Test 2: malicious GET request + $headers['X-Crowdsec-Appsec-Uri'] = '/.env'; + $response = $client->getAppSecDecision($headers); + $this->assertEquals(['action' => 'ban', 'http_status' => 403], $response, 'Should receive 403'); + // Test 3: clean POST request + $headers['X-Crowdsec-Appsec-Verb'] = 'POST'; + $headers['X-Crowdsec-Appsec-Uri'] = '/login'; + $response = $client->getAppSecDecision($headers, 'something'); + $this->assertEquals(['action' => 'allow', 'http_status' => 200], $response, 'Should receive 200'); + // Test 4: malicious POST request + $headers['X-Crowdsec-Appsec-Uri'] = '/login'; + $rawBody = 'class.module.classLoader.resources.'; // Malicious payload (@see /etc/crowdsec/appsec-rules/vpatch-CVE-2022-22965.yaml) + // Required header for file_get_contents handler + if ('FileGetContents' === $requestHandler) { + $headers['Content-type'] = 'application/x-www-form-urlencoded'; + } + $response = $client->getAppSecDecision($headers, $rawBody); + + $this->assertEquals(['action' => 'ban', 'http_status' => 403], $response, 'Should receive 403'); + } + + /** + * @dataProvider requestHandlerProvider + * + * @group timeout + * (requires APPSEC to answer with a delay of 500ms or more) + */ + public function testAppSecDecisionTimeout($requestHandler) + { + $bouncerKey = getenv('BOUNCER_KEY'); + if (!$bouncerKey) { + $this->fail('BOUNCER_KEY is not set'); + } + if ('FileGetContents' === $requestHandler) { + $client = new Bouncer($this->configs, new FileGetContents($this->configs)); + } else { + // Curl by default + $client = new Bouncer($this->configs); + } + $this->checkRequestHandler($client, $requestHandler); + + $headers = [ + 'X-Crowdsec-Appsec-Api-Key' => $bouncerKey, + 'X-Crowdsec-Appsec-Ip' => TestConstants::BAD_IP, + 'X-Crowdsec-Appsec-Host' => 'example.com', + 'X-Crowdsec-Appsec-User-Agent' => 'Mozilla/5.0', + 'X-Crowdsec-Appsec-Verb' => 'GET', + 'X-Crowdsec-Appsec-Uri' => '/login', + ]; + + // Test 1: clean GET request with timeout from AppSec + try { + $client->getAppSecDecision($headers); + } catch (TimeoutException $e) { + $error = $e->getMessage(); + if ('FileGetContents' === $requestHandler) { + PHPUnitUtil::assertRegExp($this, '/^file_get_contents call timeout/', $error, 'Should be file_get_contents timeout'); + } else { + // Curl by default + PHPUnitUtil::assertRegExp($this, '/^CURL call timeout/', $error, 'Should be CURL timeout'); + } + } + } + /** * @return void */ diff --git a/tests/Integration/WatcherClient.php b/tests/Integration/WatcherClient.php index 45d680f..e36323b 100644 --- a/tests/Integration/WatcherClient.php +++ b/tests/Integration/WatcherClient.php @@ -97,7 +97,7 @@ private function ensureLogin(): void { if (!$this->token) { $data = [ - 'scenarios' => [], + 'scenarios' => [], ]; $credentials = $this->manageRequest( 'POST', diff --git a/tests/MockedData.php b/tests/MockedData.php index 40c0a67..79ce7a9 100644 --- a/tests/MockedData.php +++ b/tests/MockedData.php @@ -32,5 +32,9 @@ class MockedData public const UNAUTHORIZED = << Constants::AUTH_KEY, 'api_key' => TestConstants::API_KEY, 'api_timeout' => TestConstants::API_TIMEOUT, + 'api_connect_timeout' => TestConstants::API_CONNECT_TIMEOUT, + 'appsec_timeout_ms' => TestConstants::APPSEC_TIMEOUT_MS, + 'appsec_connect_timeout_ms' => TestConstants::APPSEC_CONNECT_TIMEOUT_MS, ]; protected $tlsConfigs = [ 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, 'auth_type' => Constants::AUTH_TLS, 'api_timeout' => TestConstants::API_TIMEOUT, + 'api_connect_timeout' => TestConstants::API_CONNECT_TIMEOUT, + 'appsec_timeout_ms' => TestConstants::APPSEC_TIMEOUT_MS, + 'appsec_connect_timeout_ms' => TestConstants::APPSEC_CONNECT_TIMEOUT_MS, 'tls_cert_path' => 'tls_cert_path_test', 'tls_key_path' => 'tls_key_path_test', 'tls_verify_peer' => true, diff --git a/tests/Unit/AbstractClientTest.php b/tests/Unit/AbstractClientTest.php index c3665e6..e4a1ade 100644 --- a/tests/Unit/AbstractClientTest.php +++ b/tests/Unit/AbstractClientTest.php @@ -27,6 +27,7 @@ * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::__construct * @covers \CrowdSec\LapiClient\Bouncer::configure diff --git a/tests/Unit/BouncerTest.php b/tests/Unit/BouncerTest.php index 2e8b7ce..f0c4177 100644 --- a/tests/Unit/BouncerTest.php +++ b/tests/Unit/BouncerTest.php @@ -29,9 +29,12 @@ * @covers \CrowdSec\LapiClient\Bouncer::manageRequest * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions + * @covers \CrowdSec\LapiClient\Bouncer::getAppSecDecision + * @covers \CrowdSec\LapiClient\Bouncer::manageAppSecRequest * @covers \CrowdSec\LapiClient\Bouncer::formatUserAgent * @covers \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder * @covers \CrowdSec\LapiClient\Configuration::addConnectionNodes + * @covers \CrowdSec\LapiClient\Configuration::addAppSecNodes * @covers \CrowdSec\LapiClient\Configuration::validate */ final class BouncerTest extends AbstractClient @@ -84,6 +87,30 @@ public function testFilteredDecisionsParams() $mockClient->getFilteredDecisions(['ip' => '1.2.3.4']); } + public function testAppSecDecisionParams() + { + $mockClient = $this->getMockBuilder('CrowdSec\LapiClient\Bouncer') + ->enableOriginalConstructor() + ->setConstructorArgs(['configs' => $this->configs]) + ->onlyMethods(['requestAppSec']) + ->getMock(); + + $headers = [ + 'User-Agent' => Constants::USER_AGENT_PREFIX . '_' . TestConstants::USER_AGENT_SUFFIX + . '/' . TestConstants::USER_AGENT_VERSION, + 'X-Api-Key' => TestConstants::API_KEY, + ]; + + $mockClient->expects($this->exactly(1))->method('requestAppSec') + ->withConsecutive( + [ + 'GET', + $headers, + ] + ); + $mockClient->getAppSecDecision($headers); + } + public function testRequest() { // Test a valid POST request and its return @@ -139,6 +166,61 @@ public function testRequest() $this->assertEquals('CrowdSec\LapiClient\ClientException', $errorClass, 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException'); } + public function testRequestAppSec() + { + // Test a valid POST request and its return + + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder('CrowdSec\LapiClient\Bouncer') + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will($this->returnValue( + new Response(MockedData::APPSEC_ALLOWED, MockedData::HTTP_200, []) + )); + + $response = PHPUnitUtil::callMethod( + $mockClient, + 'requestAppSec', + ['POST', [], ''] + ); + + $this->assertEquals( + json_decode(MockedData::APPSEC_ALLOWED, true), + $response, + 'Should format response as expected' + ); + + // Test a not allowed request method (PUT) + $error = ''; + $errorClass = ''; + try { + PHPUnitUtil::callMethod( + $mockClient, + 'manageAppSecRequest', + ['PUT', [], ''] + ); + } catch (ClientException $e) { + $error = $e->getMessage(); + $errorClass = \get_class($e); + } + + PHPUnitUtil::assertRegExp( + $this, + '/not allowed/', + $error, + 'Not allowed method should throw an exception before sending request' + ); + + $this->assertEquals('CrowdSec\LapiClient\ClientException', $errorClass, 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException'); + } + public function testConfigure() { $client = new Bouncer($this->configs); @@ -148,12 +230,42 @@ public function testConfigure() $client->getConfig('api_url'), 'Url should be configured by default' ); + // appsec url + $this->assertEquals( + Constants::DEFAULT_APPSEC_URL, + $client->getConfig('appsec_url'), + 'App Sec Url should be configured by default' + ); // user agent suffix $this->assertEquals( TestConstants::USER_AGENT_SUFFIX, $client->getConfig('user_agent_suffix'), 'User agent suffix should be configured' ); + // api timeout + $this->assertEquals( + TestConstants::API_TIMEOUT, + $client->getConfig('api_timeout'), + 'Api timeout should be configured' + ); + // api connect timeout + $this->assertEquals( + TestConstants::API_CONNECT_TIMEOUT, + $client->getConfig('api_connect_timeout'), + 'Api connect timeout should be configured' + ); + // appsec timeout + $this->assertEquals( + TestConstants::APPSEC_TIMEOUT_MS, + $client->getConfig('appsec_timeout_ms'), + 'App Sec timeout should be configured' + ); + // appsec connect timeout + $this->assertEquals( + TestConstants::APPSEC_CONNECT_TIMEOUT_MS, + $client->getConfig('appsec_connect_timeout_ms'), + 'App Sec connect timeout should be configured' + ); $error = ''; try { new Bouncer(['user_agent_suffix' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaa']); diff --git a/tests/Unit/CurlTest.php b/tests/Unit/CurlTest.php index fd869ef..37a0b7e 100644 --- a/tests/Unit/CurlTest.php +++ b/tests/Unit/CurlTest.php @@ -17,6 +17,7 @@ use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\Tests\MockedData; +use CrowdSec\LapiClient\TimeoutException; /** * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder @@ -25,10 +26,13 @@ * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions * @covers \CrowdSec\LapiClient\Bouncer::manageRequest + * @covers \CrowdSec\LapiClient\Bouncer::getAppSecDecision + * @covers \CrowdSec\LapiClient\Bouncer::manageAppSecRequest */ final class CurlTest extends AbstractClient { @@ -71,4 +75,103 @@ public function testFilteredDecisions() 'Success get filtered decisions' ); } + + public function testAppSecDecision() + { + // Success test + $mockCurlRequest = $this->getCurlMock(['exec', 'getResponseHttpCode']); + $mockCurlRequest->method('exec')->willReturn( + MockedData::APPSEC_ALLOWED + ); + $mockCurlRequest->method('getResponseHttpCode')->willReturn( + MockedData::HTTP_200 + ); + $client = new Bouncer($this->configs, $mockCurlRequest); + $headers = [ + 'X-Crowdsec-Appsec-Ip' => 'test-value', + 'X-Crowdsec-Appsec-Host' => 'test-value', + 'X-Crowdsec-Appsec-User-Agent' => 'test-value', + 'X-Crowdsec-Appsec-Verb' => 'test-value', + 'X-Crowdsec-Appsec-Uri' => 'test-value', + 'X-Crowdsec-Appsec-Api-Key' => 'test-value', + ]; + $appSecResponse = $client->getAppSecDecision($headers); + + $this->assertEquals( + json_decode(MockedData::APPSEC_ALLOWED, true), + $appSecResponse, + 'Success get appsec decision' + ); + } + + public function testAppSecDecisionWithTimeout() + { + // Success test + $mockCurlRequest = $this->getCurlMock(['exec', 'error', 'errno']); + $mockCurlRequest->method('exec')->willReturn(false); + $mockCurlRequest->method('errno')->willReturn(\CURLE_OPERATION_TIMEOUTED); + $mockCurlRequest->method('error')->willReturn('Operation timed out'); + + $client = new Bouncer($this->configs, $mockCurlRequest); + $headers = [ + 'X-Crowdsec-Appsec-Ip' => 'test-value', + 'X-Crowdsec-Appsec-Host' => 'test-value', + 'X-Crowdsec-Appsec-User-Agent' => 'test-value', + 'X-Crowdsec-Appsec-Verb' => 'test-value', + 'X-Crowdsec-Appsec-Uri' => 'test-value', + 'X-Crowdsec-Appsec-Api-Key' => 'test-value', + ]; + + $error = false; + $message = ''; + try { + $client->getAppSecDecision($headers); + } catch (TimeoutException $e) { + $error = true; + $message = $e->getMessage(); + } + + $this->assertEquals( + true, + $error, + 'A timeout should be thrown' + ); + + $this->assertEquals( + 'CURL call timeout: Operation timed out', + $message, + 'A timeout should be thrown' + ); + } + + public function testFilteredDecisionsWithTimeout() + { + // Success test + $mockCurlRequest = $this->getCurlMock(['exec', 'error', 'errno']); + $mockCurlRequest->method('exec')->willReturn(false); + $mockCurlRequest->method('errno')->willReturn(\CURLE_OPERATION_TIMEOUTED); + $mockCurlRequest->method('error')->willReturn('Operation timed out'); + + $client = new Bouncer($this->configs, $mockCurlRequest); + + $error = false; + $message = ''; + try { + $client->getFilteredDecisions(); + } catch (TimeoutException $e) { + $error = true; + $message = $e->getMessage(); + } + $this->assertEquals( + true, + $error, + 'A timeout should be thrown' + ); + + $this->assertEquals( + 'CURL call timeout: Operation timed out', + $message, + 'A timeout should be thrown' + ); + } } diff --git a/tests/Unit/FileGetContentsTest.php b/tests/Unit/FileGetContentsTest.php index 59977c9..b034b88 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -20,6 +20,7 @@ use CrowdSec\Common\Client\HttpMessage\Request; use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\Tests\MockedData; +use CrowdSec\LapiClient\TimeoutException; /** * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder @@ -29,6 +30,7 @@ * @uses \CrowdSec\LapiClient\Bouncer::manageRequest * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions @@ -80,4 +82,39 @@ public function testFilteredDecisions() 'Success get decisions stream' ); } + + public function testFilteredDecisionsWithTimeout() + { + // Timeout test + $mockFGCRequest = $this->getFGCMock(['exec']); + $mockFGCRequest->method('exec') + ->willReturnCallback(function () { + // Trigger a warning that will be caught by the method's error handler + trigger_error('it appears that request timed out', \E_USER_ERROR); + + // Simulate a failure response + return ['response' => false]; + }); + + $client = new Bouncer($this->configs, $mockFGCRequest); + $error = false; + $message = ''; + try { + $client->getFilteredDecisions(); + } catch (TimeoutException $e) { + $error = true; + $message = $e->getMessage(); + } + $this->assertEquals( + true, + $error, + 'A timeout should be thrown' + ); + + $this->assertEquals( + 'file_get_contents call timeout: it appears that request timed out', + $message, + 'A timeout should be thrown' + ); + } } diff --git a/tests/scripts/bouncer/appsec-decision.php b/tests/scripts/bouncer/appsec-decision.php new file mode 100644 index 0000000..aa6239e --- /dev/null +++ b/tests/scripts/bouncer/appsec-decision.php @@ -0,0 +1,43 @@ + and are required' . \PHP_EOL + . 'Usage: php appsec-decisions.php []' + . \PHP_EOL + . 'Example: php appsec-decisions.php \'o7bpEAmyNF/YXhcJRSgV+HMDrfrDVRqnhp0bLjRqPVw\' \'{"X-Crowdsec-Appsec-Ip":"1.2.3.4","X-Crowdsec-Appsec-Uri":"/wsstatusevents/eventhandler.asmx","X-Crowdsec-Appsec-Host":"example.com","X-Crowdsec-Appsec-Verb":"POST","X-Crowdsec-Appsec-User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Firefox/68.0"}\' \'http://crowdsec:7422\' \'class.module.classLoader.resources.\'' + . \PHP_EOL + ); +} + +if (is_null($headers)) { + exit('Param is not a valid json' . \PHP_EOL + . 'Usage: php appsec-decision.php []' + . \PHP_EOL); +} + +echo \PHP_EOL . 'Instantiate bouncer ...' . \PHP_EOL; +// Config to use appsec_url +$configs = [ + 'appsec_url' => $appSecUrl, + 'api_key' => $apiKey, +]; +$logger = new ConsoleLog(); +$client = new Bouncer($configs, null, $logger); +echo 'Bouncer instantiated' . \PHP_EOL; + +$headers += ['X-Crowdsec-Appsec-Api-Key' => $apiKey]; + +echo 'Calling ' . $client->getConfig('appsec_url') . ' ...' . \PHP_EOL; +echo 'Headers: '; +print_r(json_encode($headers)); +$response = $client->getAppSecDecision($headers, $rawBody); +echo \PHP_EOL . 'Decision response is:' . json_encode($response) . \PHP_EOL; diff --git a/tests/scripts/bouncer/request-handler-override/appsec-decision.php b/tests/scripts/bouncer/request-handler-override/appsec-decision.php new file mode 100644 index 0000000..6d51e78 --- /dev/null +++ b/tests/scripts/bouncer/request-handler-override/appsec-decision.php @@ -0,0 +1,45 @@ + and are required' . \PHP_EOL + . 'Usage: php appsec-decisions.php []' + . \PHP_EOL + . 'Example: php appsec-decisions.php \'o7bpEAmyNF/YXhcJRSgV+HMDrfrDVRqnhp0bLjRqPVw\' \'{"X-Crowdsec-Appsec-Ip":"1.2.3.4","X-Crowdsec-Appsec-Uri":"/wsstatusevents/eventhandler.asmx","X-Crowdsec-Appsec-Host":"example.com","X-Crowdsec-Appsec-Verb":"POST","X-Crowdsec-Appsec-User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Firefox/68.0"}\' http://crowdsec:7422 \'class.module.classLoader.resources.\'' + . \PHP_EOL + ); +} + +if (is_null($headers)) { + exit('Param is not a valid json' . \PHP_EOL + . 'Usage: php appsec-decision.php []' + . \PHP_EOL); +} + +echo \PHP_EOL . 'Instantiate bouncer ...' . \PHP_EOL; +// Config to use appsec_url +$configs = [ + 'appsec_url' => $appSecUrl, + 'api_key' => $apiKey, +]; +$logger = new ConsoleLog(); +$customRequestHandler = new FileGetContents(); +$client = new Bouncer($configs, $customRequestHandler, $logger); +echo 'Bouncer instantiated' . \PHP_EOL; + +$headers += ['X-Crowdsec-Appsec-Api-Key' => $apiKey]; + +echo 'Calling ' . $client->getConfig('appsec_url') . ' ...' . \PHP_EOL; +echo 'Headers: '; +print_r(json_encode($headers)); +$response = $client->getAppSecDecision($headers, $rawBody); +echo \PHP_EOL . 'Decision response is:' . json_encode($response) . \PHP_EOL;