├── .githooks └── commit-msg ├── .github └── workflows │ ├── coding-standards.yml │ ├── doc-links.yml │ ├── php-sdk-development-tests.yml │ ├── release.yml │ └── test-suite.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── DEVELOPER.md ├── INSTALLATION_GUIDE.md ├── USER_GUIDE.md └── images │ ├── logo_crowdsec.png │ └── screenshots │ ├── front-ban.jpg │ └── front-captcha.jpg ├── patches └── gregwar-captcha-constructor.patch ├── scripts ├── bounce.php ├── clear-cache.php ├── prune-cache.php ├── push-usage-metrics.php ├── refresh-cache.php └── settings.php.dist ├── src ├── Bouncer.php └── Constants.php ├── tests ├── Integration │ ├── GeolocationTest.php │ ├── IpVerificationTest.php │ ├── StandaloneBouncerNoResponse.php │ ├── TestHelpers.php │ └── WatcherClient.php ├── PHPUnitUtil.php ├── Unit │ └── BouncerTest.php ├── end-to-end │ ├── .eslintignore │ ├── .eslintrc │ ├── .gitignore │ ├── .prettierrc │ ├── CustomEnvironment.js │ ├── __scripts__ │ │ ├── run-tests.sh │ │ └── test-init.sh │ ├── __tests__ │ │ ├── 1-live-mode.js │ │ ├── 10-appsec-timeout-bypass.js │ │ ├── 11-appsec-max-body-ban.js │ │ ├── 12-appsec-upload.js │ │ ├── 2-live-mode-with-geolocation.js │ │ ├── 3-stream-mode.js │ │ ├── 4-geolocation.js │ │ ├── 5-display-error-off.js │ │ ├── 6-display-error-on.js │ │ ├── 7-appsec.js │ │ ├── 8-appsec-timeout-captcha.js │ │ └── 9-appsec-timeout-ban.js │ ├── assets │ │ ├── small-enough.jpg │ │ └── too-big.jpg │ ├── jest-playwright.config.js │ ├── jest.config.js │ ├── package.json │ ├── settings │ │ └── base.php.dist │ ├── testSequencer.js │ └── utils │ │ ├── constants.js │ │ ├── helpers.js │ │ ├── icon.png │ │ └── watcherClient.js └── scripts │ ├── clear-cache.php │ ├── public │ ├── cache-actions.php │ ├── geolocation-test.php │ ├── protected-page.php │ ├── testappsec-upload.php │ └── testappsec.php │ └── standalone-check-ip-live.php └── tools └── coding-standards ├── .gitignore ├── composer.json ├── php-cs-fixer └── .php-cs-fixer.dist.php ├── phpmd └── rulesets.xml ├── phpstan └── phpstan.neon ├── phpunit └── phpunit.xml └── psalm └── psalm.xml /.githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ]; then 4 | echo "Missing argument (commit message). Did you try to run this manually?" 5 | exit 1 6 | fi 7 | 8 | commitTitle="$(head -n 1 < "$1")" 9 | 10 | # ignore merge 11 | if echo "$commitTitle" | grep -qE "^Merge"; then 12 | echo "Commit hook: ignoring merge" 13 | exit 0 14 | fi 15 | 16 | # check commit message 17 | REGEX='^(feat|fix|docs|style|refactor|ci|test|chore|comment)\(.*\)\:.*' 18 | if ! echo "$commitTitle" | grep -qE "${REGEX}"; then 19 | echo "Your commit title '$commitTitle' did not follow conventional commit message rules:" 20 | echo "Please comply with the regex ${REGEX}" 21 | exit 1 22 | fi 23 | -------------------------------------------------------------------------------- /.github/workflows/coding-standards.yml: -------------------------------------------------------------------------------- 1 | name: Coding standards 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - '**.md' 8 | workflow_dispatch: 9 | inputs: 10 | coverage_report: 11 | type: boolean 12 | description: Generate PHPUNIT Code Coverage report 13 | default: false 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Allow ddev get to use a GitHub token to prevent rate limiting by tests 20 | DDEV_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | jobs: 23 | coding-standards: 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] 28 | 29 | name: Coding standards 30 | runs-on: ubuntu-latest 31 | if: ${{ !contains(github.event.head_commit.message, 'chore(') }} 32 | env: 33 | EXTENSION_PATH: "my-code/standalone-bouncer" 34 | DDEV_PROJECT: "crowdsec-standalone-bouncer" 35 | 36 | steps: 37 | 38 | - name: Install DDEV 39 | # @see https://ddev.readthedocs.io/en/stable/#installationupgrade-script-linux-and-macos-armarm64-and-amd64-architectures 40 | run: | 41 | curl -fsSL https://apt.fury.io/drud/gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/ddev.gpg > /dev/null 42 | echo "deb [signed-by=/etc/apt/trusted.gpg.d/ddev.gpg] https://apt.fury.io/drud/ * *" | sudo tee /etc/apt/sources.list.d/ddev.list 43 | sudo apt-get -q update 44 | sudo apt-get -q -y install libnss3-tools ddev 45 | mkcert -install 46 | ddev config global --instrumentation-opt-in=false --omit-containers=ddev-ssh-agent 47 | 48 | - name: Create empty PHP DDEV project 49 | run: ddev config --project-type=php --project-name=${{env.DDEV_PROJECT}} --php-version=${{ matrix.php-version }} 50 | 51 | - name: Add Redis, Memcached and Crowdsec 52 | run: | 53 | ddev get ddev/ddev-redis 54 | ddev get ddev/ddev-memcached 55 | # override redis.conf 56 | ddev get julienloizelet/ddev-tools 57 | ddev get julienloizelet/ddev-crowdsec-php 58 | 59 | - name: Start DDEV 60 | uses: nick-fields/retry@v2 61 | with: 62 | timeout_minutes: 5 63 | max_attempts: 3 64 | shell: bash 65 | command: ddev start 66 | 67 | - name: Some DEBUG information 68 | run: | 69 | ddev --version 70 | ddev exec php -v 71 | ddev php -r "echo phpversion('memcached');" 72 | 73 | - name: Clone PHP lib Crowdsec files 74 | uses: actions/checkout@v4 75 | with: 76 | path: my-code/standalone-bouncer 77 | 78 | - name: Install CrowdSec lib dependencies 79 | run: ddev composer update --working-dir ./${{env.EXTENSION_PATH}} 80 | 81 | - name: Install Coding standards tools 82 | run: ddev composer update --working-dir=./${{env.EXTENSION_PATH}}/tools/coding-standards 83 | 84 | - name: Run PHPCS 85 | run: ddev phpcs ./${{env.EXTENSION_PATH}}/tools/coding-standards ${{env.EXTENSION_PATH}}/src PSR12 86 | 87 | - name: Run PHPSTAN 88 | run: ddev phpstan /var/www/html/${{env.EXTENSION_PATH}}/tools/coding-standards phpstan/phpstan.neon /var/www/html/${{env.EXTENSION_PATH}}/src 89 | 90 | - name: Run PHPMD 91 | run: ddev phpmd ./${{env.EXTENSION_PATH}}/tools/coding-standards phpmd/rulesets.xml ../../src 92 | 93 | - name: Run PSALM 94 | if: contains(fromJson('["7.4","8.0","8.1","8.2","8.3"]'),matrix.php-version) 95 | run: ddev psalm ./${{env.EXTENSION_PATH}}/tools/coding-standards ./${{env.EXTENSION_PATH}}/tools/coding-standards/psalm 96 | 97 | - name: Prepare for Code Coverage 98 | if: github.event.inputs.coverage_report == 'true' 99 | run: | 100 | mkdir ${{ github.workspace }}/cfssl 101 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl 102 | ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests 103 | ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests 104 | cd ${{env.EXTENSION_PATH}}/tests 105 | sha256sum -c GeoLite2-Country.tar.gz.sha256.txt 106 | sha256sum -c GeoLite2-City.tar.gz.sha256.txt 107 | tar -xf GeoLite2-Country.tar.gz 108 | tar -xf GeoLite2-City.tar.gz 109 | rm GeoLite2-Country.tar.gz GeoLite2-Country.tar.gz.sha256.txt GeoLite2-City.tar.gz GeoLite2-City.tar.gz.sha256.txt 110 | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV 111 | 112 | - name: Run PHPUNIT Code Coverage 113 | if: github.event.inputs.coverage_report == 'true' 114 | run: | 115 | ddev xdebug 116 | ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=${{ env.BOUNCER_KEY }} APPSEC_URL=http://crowdsec:7422 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 117 | cat ${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt 118 | -------------------------------------------------------------------------------- /.github/workflows/doc-links.yml: -------------------------------------------------------------------------------- 1 | name: Documentation links 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | markdown-test: 13 | name: Markdown files test 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Clone sources 18 | uses: actions/checkout@v4 19 | with: 20 | path: extension 21 | 22 | - name: Launch localhost server 23 | run: | 24 | sudo npm install --global http-server 25 | http-server ./extension & 26 | 27 | - name: Set up Ruby 2.6 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: 2.6 31 | 32 | - name: Check links in Markdown files 33 | run: | 34 | gem install awesome_bot 35 | cd extension 36 | awesome_bot --files README.md --allow-dupe --allow 401,301 --skip-save-results --white-list ddev.site --base-url http://localhost:8080/ 37 | awesome_bot docs/*.md --skip-save-results --allow-dupe --allow 401,403,301 --white-list ddev.site,https://crowdsec,http://crowdsec,http://localhost:7422,php.net/supported-versions.php --base-url http://localhost:8080/docs/ 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | # example: gh workflow run release.yml -f tag_name=v1.1.4 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag_name: 7 | type: string 8 | required: true 9 | 10 | jobs: 11 | prepare-release: 12 | name: Prepare release 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check naming convention 17 | run: | 18 | VERIF=$(echo ${{ github.event.inputs.tag_name }} | grep -E "^v([0-9]{1,}\.)([0-9]{1,}\.)([0-9]{1,})(-(alpha|beta)\.[0-9]{1,})?$") 19 | if [ ! ${VERIF} ] 20 | then 21 | echo "Tag name '${{ github.event.inputs.tag_name }}' does not comply with naming convention vX.Y.Z" 22 | exit 1 23 | fi 24 | 25 | - name: Set version number without v 26 | run: | 27 | echo "VERSION_NUMBER=$(echo ${{ github.event.inputs.tag_name }} | sed 's/v//g' )" >> $GITHUB_ENV 28 | 29 | - name: Clone sources 30 | uses: actions/checkout@v4 31 | 32 | - name: Check version ${{ env.VERSION_NUMBER }} consistency in files 33 | # Check src/Constants.php and CHANGELOG.md 34 | run: | 35 | # Check public const VERSION = 'vVERSION_NUMBER'; in CHANGELOG.md in src/Constants.php 36 | CONSTANT_VERSION=$(grep -E "public const VERSION = 'v(.*)';" src/Constants.php | sed 's/ //g') 37 | if [[ $CONSTANT_VERSION == "publicconstVERSION='v${{ env.VERSION_NUMBER }}';" ]] 38 | then 39 | echo "CONSTANT VERSION OK" 40 | else 41 | echo "CONSTANT VERSION KO" 42 | exit 1 43 | fi 44 | 45 | # Check top ## [VERSION_NUMBER](GITHUB_URL/releases/tag/vVERSION_NUMBER) - yyyy-mm-dd in CHANGELOG.md 46 | CURRENT_DATE=$(date +'%Y-%m-%d') 47 | echo $CURRENT_DATE 48 | CHANGELOG_VERSION=$(grep -o -E "## \[(.*)\].* - $CURRENT_DATE" CHANGELOG.md | head -1 | sed 's/ //g') 49 | echo $CHANGELOG_VERSION 50 | if [[ $CHANGELOG_VERSION == "##[${{ env.VERSION_NUMBER }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v${{ env.VERSION_NUMBER }})-$CURRENT_DATE" ]] 51 | then 52 | echo "CHANGELOG VERSION OK" 53 | else 54 | echo "CHANGELOG VERSION KO" 55 | exit 1 56 | fi 57 | 58 | # Check top [_Compare with previous release_](GITHUB_URL/compare/vLAST_TAG...vVERSION_NUMBER) in CHANGELOG.md 59 | COMPARISON=$(grep -oP "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/\K(.*)$" CHANGELOG.md | head -1) 60 | LAST_TAG=$(curl -Ls -o /dev/null -w %{url_effective} $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/latest | grep -oP "\/tag\/\K(.*)$") 61 | if [[ $COMPARISON == "$LAST_TAG...v${{ env.VERSION_NUMBER }})" ]] 62 | then 63 | echo "VERSION COMPARISON OK" 64 | else 65 | echo "VERSION COMPARISON KO" 66 | echo $COMPARISON 67 | echo "$LAST_TAG...v${{ env.VERSION_NUMBER }})" 68 | exit 1 69 | fi 70 | 71 | - name: Create Tag ${{ github.event.inputs.tag_name }} 72 | uses: actions/github-script@v7 73 | with: 74 | github-token: ${{ github.token }} 75 | script: | 76 | github.rest.git.createRef({ 77 | owner: context.repo.owner, 78 | repo: context.repo.repo, 79 | ref: "refs/tags/${{ github.event.inputs.tag_name }}", 80 | sha: context.sha 81 | }) 82 | 83 | - name: Prepare release notes 84 | run: | 85 | VERSION_RELEASE_NOTES=$(awk -v ver="[${{ env.VERSION_NUMBER }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v${{ env.VERSION_NUMBER }})" '/^## / { if (p) { exit }; if ($2 == ver) { p=1; next} } p && NF' CHANGELOG.md | sed ':a;N;$!ba;s/\n---/ /g') 86 | echo "$VERSION_RELEASE_NOTES" >> CHANGELOG.txt 87 | 88 | - name: Create release ${{ env.VERSION_NUMBER }} 89 | uses: softprops/action-gh-release@v1 90 | with: 91 | body_path: CHANGELOG.txt 92 | name: ${{ env.VERSION_NUMBER }} 93 | tag_name: ${{ github.event.inputs.tag_name }} 94 | draft: ${{ github.event.inputs.draft }} 95 | prerelease: ${{ github.event.inputs.prerelease }} 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | vendor 3 | composer.lock 4 | composer-dev.* 5 | 6 | #Tools 7 | .php-cs-fixer.cache 8 | .php-cs-fixer.php 9 | 10 | # Scripts 11 | scripts/settings.php 12 | .logs 13 | .cache 14 | 15 | # MaxMind databases 16 | *.mmdb 17 | 18 | # Uploads test 19 | tests/scripts/public/uploads/* 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## SemVer public API 8 | 9 | The [public API](https://semver.org/spec/v2.0.0.html#spec-item-1) of this library consists of all public or 10 | protected methods, properties and constants belonging to the `src` folder and of all files in the `scripts` folder. 11 | 12 | --- 13 | 14 | ## [1.5.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.5.0) - 2025-01-10 15 | 16 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.4.0...v1.5.0) 17 | 18 | ### Changed 19 | 20 | - Do not count a "processed" usage metrics when the IP is not bounced at all due to business rules (i.e. when `shouldBounceCurrentIp` returns false). 21 | 22 | --- 23 | 24 | ## [1.4.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.4.0) - 2025-01-09 25 | 26 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.3.1...v1.4.0) 27 | 28 | ### Added 29 | 30 | - Add `push-usage-metrics.php` script 31 | 32 | --- 33 | 34 | ## [1.3.1](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.3.1) - 2024-12-12 35 | 36 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.3.0...v1.3.1) 37 | 38 | ### Fixed 39 | 40 | - Fix Captcha deprecated warning in PHP 8.4 41 | 42 | --- 43 | 44 | ## [1.3.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.3.0) - 2024-11-05 45 | 46 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.2.0...v1.3.0) 47 | 48 | ### Added 49 | 50 | - Add multipart request support for AppSec 51 | - Add `appsec_max_body_size_kb` and `appsec_body_size_exceeded_action` settings 52 | 53 | --- 54 | 55 | ## [1.2.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.2.0) - 2024-10-04 56 | 57 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.1.0...v1.2.0) 58 | 59 | ### Added 60 | 61 | - Add AppSec support 62 | - Add `use_appsec`, `appsec_url`, `appsec_timeout_ms`, `appsec_connect_timeout_ms` and `appsec_fallback_remediation` settings 63 | 64 | --- 65 | 66 | ## [1.1.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.1.0) - 2023-12-14 67 | 68 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v1.0.0...v1.1.0) 69 | 70 | ### Added 71 | 72 | - Add `api_connect_timeout` setting 73 | 74 | --- 75 | 76 | ## [1.0.0](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v1.0.0) - 2023-04-27 77 | 78 | [_Compare with previous release_](https://github.com/crowdsecurity/cs-standalone-php-bouncer/compare/v0.0.1...v1.0.0) 79 | 80 | ### Changed 81 | 82 | - Change version to `1.0.0`: first stable release 83 | 84 | --- 85 | 86 | ## [0.0.1](https://github.com/crowdsecurity/cs-standalone-php-bouncer/releases/tag/v0.0.1) - 2023-04-27 87 | 88 | - Initial release 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 crowdsecurity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/main/docs/images/logo_crowdsec.png) 2 | 3 | # CrowdSec standalone PHP bouncer 4 | 5 | > The official standalone PHP bouncer for the CrowdSec LAPI 6 | 7 | ![Version](https://img.shields.io/github/v/release/crowdsecurity/cs-standalone-php-bouncer?include_prereleases) 8 | [![Test suite](https://github.com/crowdsecurity/cs-standalone-php-bouncer/actions/workflows/test-suite.yml/badge.svg)](https://github.com/crowdsecurity/cs-standalone-php-bouncer/actions/workflows/test-suite.yml) 9 | [![Coding standards](https://github.com/crowdsecurity/cs-standalone-php-bouncer/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/crowdsecurity/cs-standalone-php-bouncer/actions/workflows/coding-standards.yml) 10 | ![Licence](https://img.shields.io/github/license/crowdsecurity/cs-standalone-php-bouncer) 11 | 12 | 13 | :books: Documentation 14 | :diamond_shape_with_a_dot_inside: Hub 15 | :speech_balloon: Discourse Forum 16 | 17 | 18 | ## Overview 19 | 20 | This bouncer allows you to protect your PHP application from IPs that have been detected by CrowdSec. Depending on 21 | the decision taken by CrowdSec, user will either get denied (403) or have to fill a captcha (401). 22 | 23 | It uses the [PHP `auto_prepend_file` mechanism](https://www.php.net/manual/en/ini.core.php#ini.auto-prepend-file) and 24 | the [Crowdsec php bouncer library](https://github.com/crowdsecurity/php-cs-bouncer) to provide bouncer/IPS capability 25 | directly in your PHP application. 26 | 27 | It supports "ban" and "captcha" remediations, and all decisions of type Ip, Range or Country (geolocation). 28 | 29 | 30 | ## Usage 31 | 32 | See [User Guide](https://github.com/crowdsecurity/cs-standalone-php-bouncer/blob/main/docs/USER_GUIDE.md) 33 | 34 | ## Installation 35 | 36 | See [Installation Guide](https://github.com/crowdsecurity/cs-standalone-php-bouncer/blob/main/docs/INSTALLATION_GUIDE.md) 37 | 38 | 39 | ## Developer guide 40 | 41 | See [Developer Guide](https://github.com/crowdsecurity/cs-standalone-php-bouncer/blob/main/docs/DEVELOPER.md) 42 | 43 | 44 | ## License 45 | 46 | [MIT](https://github.com/crowdsecurity/cs-standalone-php-bouncer/blob/main/LICENSE) 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crowdsec/standalone-bouncer", 3 | "description": "The official CrowdSec standalone bouncer for PHP websites", 4 | "type": "lib", 5 | "license": "MIT", 6 | "minimum-stability": "stable", 7 | "homepage": "https://github.com/crowdsecurity/cs-standalone-php-bouncer", 8 | "keywords": [ 9 | "security", 10 | "crowdsec", 11 | "waf", 12 | "middleware", 13 | "http", 14 | "blocker", 15 | "bouncer", 16 | "captcha", 17 | "geoip", 18 | "ip", 19 | "ip range" 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "CrowdSecStandalone\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "CrowdSecStandalone\\Tests\\": "tests/" 29 | } 30 | }, 31 | "authors": [ 32 | { 33 | "name": "CrowdSec", 34 | "email": "info@crowdsec.net" 35 | }, 36 | { 37 | "name": "Julien Loizelet", 38 | "homepage": "https://github.com/julienloizelet/", 39 | "role": "Developer" 40 | } 41 | ], 42 | "require": { 43 | "php": ">=7.2.5", 44 | "crowdsec/bouncer": "^4.2.0", 45 | "crowdsec/remediation-engine": "^4.2.0", 46 | "crowdsec/common": "^3.0.0", 47 | "cweagans/composer-patches": "^1.7", 48 | "mlocati/ip-lib": "^1.18" 49 | }, 50 | "require-dev": { 51 | "phpunit/phpunit": "^8.5.30 || ^9.3", 52 | "mikey179/vfsstream": "^1.6.11", 53 | "nikic/php-parser": "^4.18" 54 | }, 55 | "config": { 56 | "allow-plugins": { 57 | "cweagans/composer-patches": true 58 | } 59 | }, 60 | "extra": { 61 | "patches": { 62 | "gregwar/captcha": { 63 | "Fix deprecation in CaptchaBuilder constructor": "patches/gregwar-captcha-constructor.patch" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/DEVELOPER.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | # CrowdSec standalone PHP bouncer 3 | 4 | ## Developer guide 5 | 6 | **Table of Contents** 7 | 8 | 9 | 10 | - [Local development](#local-development) 11 | - [DDEV setup](#ddev-setup) 12 | - [DDEV installation](#ddev-installation) 13 | - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) 14 | - [DDEV Usage](#ddev-usage) 15 | - [Add CrowdSec bouncer and watcher](#add-crowdsec-bouncer-and-watcher) 16 | - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) 17 | - [Find IP of your docker services](#find-ip-of-your-docker-services) 18 | - [Unit test](#unit-test) 19 | - [Integration test](#integration-test) 20 | - [Auto-prepend mode (standalone mode)](#auto-prepend-mode-standalone-mode) 21 | - [End-to-end tests](#end-to-end-tests) 22 | - [Coding standards](#coding-standards) 23 | - [Generate CrowdSec tools and settings on start](#generate-crowdsec-tools-and-settings-on-start) 24 | - [Redis debug](#redis-debug) 25 | - [Memcached debug](#memcached-debug) 26 | - [Example scripts](#example-scripts) 27 | - [Clear cache script](#clear-cache-script) 28 | - [Full Live mode example](#full-live-mode-example) 29 | - [Set up the context](#set-up-the-context) 30 | - [Get the remediation the clean IP "1.2.3.4"](#get-the-remediation-the-clean-ip-1234) 31 | - [Now ban range 1.2.3.4 to 1.2.3.7 for 12h](#now-ban-range-1234-to-1237-for-12h) 32 | - [Clear cache and get the new remediation](#clear-cache-and-get-the-new-remediation) 33 | - [Discover the CrowdSec LAPI](#discover-the-crowdsec-lapi) 34 | - [Use the CrowdSec cli (`cscli`)](#use-the-crowdsec-cli-cscli) 35 | - [Add decision for an IP or a range of IPs](#add-decision-for-an-ip-or-a-range-of-ips) 36 | - [Add decision to ban or captcha a country](#add-decision-to-ban-or-captcha-a-country) 37 | - [Delete decisions](#delete-decisions) 38 | - [Create a bouncer](#create-a-bouncer) 39 | - [Create a watcher](#create-a-watcher) 40 | - [Use the web container to call LAPI](#use-the-web-container-to-call-lapi) 41 | - [Update documentation table of contents](#update-documentation-table-of-contents) 42 | - [Commit message](#commit-message) 43 | - [Allowed message `type` values](#allowed-message-type-values) 44 | - [Release process](#release-process) 45 | 46 | 47 | 48 | 49 | 50 | ## Local development 51 | 52 | There are many ways to install this library on a local PHP environment. 53 | 54 | We are using [DDEV](https://ddev.readthedocs.io/en/stable/) because it is quite simple to use and customize. 55 | 56 | Of course, you may use your own local stack, but we provide here some useful tools that depends on DDEV. 57 | 58 | 59 | ### DDEV setup 60 | 61 | For a quick start, follow the below steps. 62 | 63 | 64 | #### DDEV installation 65 | 66 | For the DDEV installation, please follow the [official instructions](https://ddev.readthedocs.io/en/stable/users/install/ddev-installation/). 67 | 68 | 69 | #### Prepare DDEV PHP environment 70 | 71 | The final structure of the project will look like below. 72 | 73 | ``` 74 | crowdsec-bouncer-project (choose the name you want for this folder) 75 | │ 76 | │ (your php project sources; could be a simple index.php file) 77 | │ 78 | └───.ddev 79 | │ │ 80 | │ │ (DDEV files) 81 | │ 82 | └───my-code (do not change this folder name) 83 | │ 84 | │ 85 | └───standalone-bouncer (do not change this folder name) 86 | │ 87 | │ (Clone of this repo) 88 | 89 | ``` 90 | 91 | - Create an empty folder that will contain all necessary sources: 92 | ```bash 93 | mkdir crowdsec-bouncer-project 94 | ``` 95 | 96 | - Create a DDEV php project: 97 | 98 | ```bash 99 | cd crowdsec-bouncer-project 100 | ddev config --project-type=php --php-version=8.2 --project-name=crowdsec-standalone-bouncer 101 | ``` 102 | 103 | - Add some DDEV add-ons: 104 | 105 | ```bash 106 | ddev get ddev/ddev-redis 107 | ddev get ddev/ddev-memcached 108 | ddev get julienloizelet/ddev-tools 109 | ddev get julienloizelet/ddev-crowdsec-php 110 | ``` 111 | 112 | - Clone this repo sources in a `my-code/standalone-bouncer` folder: 113 | 114 | ```bash 115 | mkdir -p my-code/standalone-bouncer 116 | cd my-code/standalone-bouncer && git clone git@github.com:crowdsecurity/cs-standalone-php-bouncer.git ./ 117 | ``` 118 | 119 | - Launch DDEV 120 | 121 | ```bash 122 | ddev start 123 | ``` 124 | This should take some times on the first launch as this will download all necessary docker images. 125 | 126 | 127 | ### DDEV Usage 128 | 129 | 130 | #### Add CrowdSec bouncer and watcher 131 | 132 | - To create a new bouncer in the CrowdSec container, run: 133 | 134 | ```bash 135 | ddev create-bouncer [name] 136 | ``` 137 | 138 | It will return the bouncer key. 139 | 140 | - To create a new watcher, run: 141 | 142 | ```bash 143 | ddev create-watcher [name] [password] 144 | ``` 145 | 146 | **N.B.** : Since we are using TLS authentication for agent, you should avoid to create a watcher with this method. 147 | 148 | 149 | #### Use composer to update or install the lib 150 | 151 | Run: 152 | 153 | ```bash 154 | ddev composer update --working-dir ./my-code/standalone-bouncer 155 | ``` 156 | 157 | For advanced usage, you can create a `composer-dev.json` file in the `my-code/standalone-bouncer` folder and run: 158 | 159 | ```bash 160 | ddev exec COMPOSER=composer-dev.json composer update --working-dir ./my-code/standalone-bouncer 161 | ``` 162 | 163 | #### Find IP of your docker services 164 | 165 | In most cases, you will test to bounce your current IP. As we are running on a docker stack, this is the local host IP. 166 | 167 | To find it, just run: 168 | 169 | ```bash 170 | ddev find-ip 171 | ``` 172 | 173 | You will have to know also the IP of the `ddev-router` container as it acts as a proxy, and you should set it in the `trust_ip_forward_array` setting. 174 | 175 | To find this IP, just run: 176 | 177 | ```bash 178 | ddev find-ip ddev-router 179 | ``` 180 | 181 | 182 | #### Unit test 183 | 184 | 185 | ```bash 186 | ddev php ./my-code/standalone-bouncer/vendor/bin/phpunit ./my-code/standalone-bouncer/tests/Unit --testdox 187 | ``` 188 | 189 | #### Integration test 190 | 191 | First, create a bouncer and keep the result key. 192 | 193 | ```bash 194 | ddev create-bouncer 195 | ``` 196 | 197 | Then, as we use a TLS ready CrowdSec container, you have to copy some certificates and key: 198 | 199 | ```bash 200 | cd crowdsec-bouncer-project 201 | mkdir cfssl 202 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* cfssl 203 | ``` 204 | 205 | Finally, run 206 | 207 | 208 | ```bash 209 | ddev exec BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-code/standalone-bouncer/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/standalone-bouncer/tests/Integration/IpVerificationTest.php 210 | ``` 211 | 212 | For geolocation Unit Test, you should first put 2 free MaxMind databases in the `tests` folder : `GeoLite2-City.mmdb` 213 | and `GeoLite2-Country.mmdb`. You can download these databases by [creating a MaxMind account](https://support.maxmind.com/hc/en-us/articles/4407099783707-Create-an-Account) and browse to the 214 | download page. 215 | 216 | 217 | Then, you can run: 218 | 219 | ```bash 220 | ddev exec BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-code/standalone-bouncer/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/standalone-bouncer/tests/Integration/GeolocationTest.php 221 | ``` 222 | 223 | **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 224 | the previous commands. 225 | 226 | **N.B**.: If you want to test with `tls` authentication, you have to add `BOUNCER_TLS_PATH` environment variable 227 | and specify the path where you store certificates and keys. For example: 228 | 229 | ```bash 230 | ddev exec USE_CURL=1 AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-code/standalone-bouncer/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/standalone-bouncer/tests/Integration/IpVerificationTest.php 231 | ``` 232 | 233 | 234 | #### Auto-prepend mode (standalone mode) 235 | 236 | Before using the bouncer in a standalone mode (i.e. with an `auto_prepend_file` directive), you should copy the [`scripts/settings.php.dist`](../scripts/settings.php.dist) file to a `scripts/settings.php` file and edit it depending on your needs. 237 | 238 | Then, to configure the Nginx service in order that it uses an `auto_prepend_file` directive pointing to the [`scripts/bounce.php`](../scripts/bounce.php) script, please run the following command from the `.ddev` folder: 239 | 240 | ```bash 241 | ddev nginx-config okaeli-add-on/native/custom_files/crowdsec/crowdsec-prepend-nginx-site.conf 242 | ``` 243 | 244 | With that done, every access to your ddev url (i.e. `https://crowdsec-standalone-bouncer.ddev.site`) will be bounce. 245 | 246 | For example, you should try to browse the following url: 247 | 248 | ``` 249 | https://crowdsec-standalone-bouncer/my-code/standalone-bouncer/tests/scripts/public/protected-page.php 250 | ``` 251 | 252 | #### End-to-end tests 253 | 254 | In auto-prepend mode, you can run some end-to-end tests. 255 | 256 | To enable auto_prepend_file directive, run: 257 | 258 | ```bash 259 | cd crowdsec-bouncer-project/.ddev && ddev nginx-config okaeli-add-on/native/custom_files/crowdsec/crowdsec-prepend-nginx-site.conf 260 | ``` 261 | 262 | We are using a Jest/Playwright Node.js stack to launch a suite of end-to-end tests. 263 | 264 | Tests code is in the `tests/end-to-end` folder. You should have to `chmod +x` the scripts you will find in `tests/end-to-end/__scripts__`. 265 | 266 | 267 | ``` 268 | cd crowdsec-bouncer-project 269 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* cfssl 270 | ``` 271 | 272 | Then you can use the `run-test.sh` script to run the tests: 273 | 274 | - the first parameter specifies if you want to run the test on your machine (`host`) or in the 275 | docker containers (`docker`). You can also use `ci` if you want to have the same behavior as in GitHub action. 276 | - the second parameter list the test files you want to execute. If empty, all the test suite will be launched. 277 | 278 | For example: 279 | 280 | ./run-tests.sh host "./__tests__/1-live-mode.js" 281 | ./run-tests.sh docker "./__tests__/1-live-mode.js" 282 | ./run-tests.sh host 283 | 284 | ##### Test in docker 285 | 286 | Before testing with the `docker` or `ci` parameter, you have to install all the required dependencies 287 | in the playwright container with this command : 288 | 289 | ./test-init.sh 290 | 291 | ##### Test on host 292 | 293 | 294 | If you want to test with the `host` parameter, you will have to install manually all the required dependencies: 295 | 296 | ``` 297 | yarn --cwd ./tests/end-to-end --force 298 | yarn global add cross-env 299 | ``` 300 | 301 | ##### Testing timeout in the CrowdSec container 302 | 303 | If you need to test a timeout, you can use the following command: 304 | 305 | Install `iproute2` 306 | ```bash 307 | ddev exec -s crowdsec apk add iproute2 308 | ``` 309 | Add the delay you want: 310 | ```bash 311 | ddev exec -s crowdsec tc qdisc add dev eth0 root netem delay 500ms 312 | ``` 313 | 314 | To remove the delay: 315 | ```bash 316 | ddev exec -s crowdsec tc qdisc del dev eth0 root netem 317 | ``` 318 | 319 | 320 | #### Coding standards 321 | 322 | We set up some coding standards tools that you will find in the `tools/coding-standards` folder. In order to use these, you will need to work with a PHP version >= 7.4 and run first: 323 | 324 | ``` 325 | ddev composer update --working-dir=./my-code/standalone-bouncer/tools/coding-standards 326 | ``` 327 | 328 | ##### PHPCS Fixer 329 | 330 | We are using the [PHP Coding Standards Fixer](https://cs.symfony.com/). With ddev, you can do the following: 331 | 332 | 333 | ```bash 334 | ddev phpcsfixer my-code/standalone-bouncer/tools/coding-standards/php-cs-fixer ../ 335 | ``` 336 | 337 | ##### PHPSTAN 338 | 339 | To use the [PHPSTAN](https://github.com/phpstan/phpstan) tool, you can run: 340 | 341 | 342 | ```bash 343 | ddev phpstan /var/www/html/my-code/standalone-bouncer/tools/coding-standards phpstan/phpstan.neon /var/www/html/my-code/standalone-bouncer/src 344 | 345 | ``` 346 | 347 | 348 | ##### PHP Mess Detector 349 | 350 | To use the [PHPMD](https://github.com/phpmd/phpmd) tool, you can run: 351 | 352 | ```bash 353 | ddev phpmd ./my-code/standalone-bouncer/tools/coding-standards phpmd/rulesets.xml ../../src 354 | ``` 355 | 356 | ##### PHPCS and PHPCBF 357 | 358 | To use [PHP Code Sniffer](https://github.com/squizlabs/PHP_CodeSniffer) tools, you can run: 359 | 360 | ```bash 361 | ddev phpcs ./my-code/standalone-bouncer/tools/coding-standards my-code/standalone-bouncer/src PSR12 362 | ``` 363 | 364 | and: 365 | 366 | ```bash 367 | ddev phpcbf ./my-code/standalone-bouncer/tools/coding-standards my-code/standalone-bouncer/src PSR12 368 | ``` 369 | 370 | 371 | ##### PSALM 372 | 373 | To use [PSALM](https://github.com/vimeo/psalm) tools, you can run: 374 | 375 | ```bash 376 | ddev psalm ./my-code/standalone-bouncer/tools/coding-standards ./my-code/standalone-bouncer/tools/coding-standards/psalm 377 | ``` 378 | 379 | ##### PHP Unit Code coverage 380 | 381 | In order to generate a code coverage report, you have to: 382 | 383 | - Enable `xdebug`: 384 | ```bash 385 | ddev xdebug 386 | ``` 387 | 388 | To generate a html report, you can run: 389 | ```bash 390 | ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php ./my-code/standalone-bouncer/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/standalone-bouncer/tools/coding-standards/phpunit/phpunit.xml 391 | 392 | ``` 393 | 394 | You should find the main report file `dashboard.html` in `tools/coding-standards/phpunit/code-coverage` folder. 395 | 396 | 397 | If you want to generate a text report in the same folder: 398 | 399 | ```bash 400 | ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl 401 | LAPI_URL=https://crowdsec:8080 APPSEC_URL=http://crowdsec:7422 MEMCACHED_DSN=memcached://memcached:11211 402 | REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-code/standalone-bouncer/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/standalone-bouncer/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./my-code/standalone-bouncer/tools/coding-standards/phpunit/code-coverage/report.txt 403 | ``` 404 | 405 | #### Generate CrowdSec tools and settings on start 406 | 407 | We use a post-start DDEV hook to: 408 | - Create a bouncer 409 | - Set bouncer key, api url and other needed values in the `scripts/auto-prepend/settings.php` file (useful to test 410 | standalone mode). 411 | - Create a watcher that we use in end-to-end tests 412 | 413 | Just copy the file and restart: 414 | ```bash 415 | cp .ddev/config_overrides/config.crowdsec.yaml .ddev/config.crowdsec.yaml 416 | ddev restart 417 | ``` 418 | 419 | #### Redis debug 420 | 421 | You should enter the `Redis` container: 422 | 423 | ```bash 424 | ddev redis-cli 425 | ``` 426 | 427 | Then, you could play with the `redis-cli` command line tool: 428 | 429 | - Display keys and databases: `INFO keyspace` 430 | 431 | - Display stored keys: `KEYS *` 432 | 433 | - Display key value: `GET [key]` 434 | 435 | - Remove a key: `DEL [key]` 436 | 437 | #### Memcached debug 438 | 439 | @see https://lzone.de/#/LZone%20Cheat%20Sheets/DevOps%20Services/memcached 440 | 441 | First, find the IP of the `Memcached` container: 442 | 443 | ```bash 444 | ddev find-ip memcached 445 | ``` 446 | 447 | Then, you could use `telnet` to interact with memcached: 448 | 449 | ``` 450 | telnet 11211 451 | ``` 452 | 453 | - `stats` 454 | 455 | - `stats items`: The first number after `items` is the slab id. Request a cache dump for each slab id, with a limit for 456 | the max number of keys to dump: 457 | 458 | - `stats cachedump 2 100` 459 | 460 | - `get ` : Read a value 461 | 462 | - `delete `: Delete a key 463 | 464 | 465 | ## Example scripts 466 | 467 | You will find some php scripts in the `tests/scripts` folder. 468 | 469 | **N.B**. : If you are not using DDEV, you can replace all `ddev exec php ` by `php` and specify the right script paths. 470 | 471 | ### Clear cache script 472 | 473 | To clear your LAPI cache, you can use the [`clear-cache.php`](../tests/scripts/clear-cache.php) script: 474 | 475 | ```bash 476 | ddev exec php my-code/standalone-bouncer/tests/scripts/clear-cache.php 477 | ``` 478 | 479 | ### Full Live mode example 480 | 481 | This example demonstrates how the PHP Lib works with cache when you are using the live mode. 482 | 483 | We will use here the [`standalone-check-ip-live.php`](../tests/scripts/standalone-check-ip-live.php). 484 | 485 | #### Set up the context 486 | 487 | Start the containers: 488 | 489 | ```bash 490 | ddev start 491 | ``` 492 | 493 | Then get a bouncer API key by copying the result of: 494 | 495 | ```bash 496 | ddev create-bouncer 497 | ``` 498 | 499 | #### Get the remediation the clean IP "1.2.3.4" 500 | 501 | Try with the `standalone-check-ip-live.php` file: 502 | 503 | 504 | ```bash 505 | ddev exec php my-code/standalone-bouncer/tests/scripts/standalone-check-ip-live.php 1.2.3.4 506 | ``` 507 | 508 | #### Now ban range 1.2.3.4 to 1.2.3.7 for 12h 509 | 510 | ```bash 511 | ddev exec -s crowdsec cscli decisions add --range 1.2.3.4/30 --duration 12h --type ban 512 | ``` 513 | 514 | #### Clear cache and get the new remediation 515 | 516 | Clear the cache: 517 | 518 | ```bash 519 | ddev exec php my-code/standalone-bouncer/scripts/clear-cache.php 520 | ``` 521 | 522 | One more time, get the remediation for the IP "1.2.3.4": 523 | 524 | ```bash 525 | ddev exec php my-code/standalone-bouncer/scripts/standalone-check-ip-live.php 1.2.3.4 526 | ``` 527 | 528 | This is a ban (and cache miss) as you can see in your terminal logs. 529 | 530 | 531 | ## Discover the CrowdSec LAPI 532 | 533 | This library interacts with a CrowdSec agent that you have installed on an accessible server. 534 | 535 | The easiest way to interact with the local API (LAPI) is to use the `cscli` tool,but it is also possible to contact it 536 | through a certain URL (e.g. `https://crowdsec:8080`). 537 | 538 | ### Use the CrowdSec cli (`cscli`) 539 | 540 | 541 | Please refer to the [CrowdSec cscli documentation](https://docs.crowdsec.net/docs/cscli/) for an exhaustive 542 | list of commands. 543 | 544 | **N.B**.: If you are using DDEV, just replace `cscli` with `ddev exec -s crowdsec cscli`. 545 | 546 | Here is a list of command that we often use to test the PHP library: 547 | 548 | #### Add decision for an IP or a range of IPs 549 | 550 | First example is a `ban`, second one is a `captcha`: 551 | 552 | ```bash 553 | cscli decisions add --ip --duration 12h --type ban 554 | cscli decisions add --ip --duration 4h --type captcha 555 | ``` 556 | 557 | For a range of IPs: 558 | 559 | ```bash 560 | cscli decisions add --range 1.2.3.4/30 --duration 12h --type ban 561 | ``` 562 | 563 | #### Add decision to ban or captcha a country 564 | ```bash 565 | cscli decisions add --scope Country --value JP --duration 4h --type ban 566 | ``` 567 | 568 | #### Delete decisions 569 | 570 | - Delete all decisions: 571 | ```bash 572 | cscli decisions delete --all 573 | ``` 574 | - Delete a decision with an IP scope 575 | ```bash 576 | cscli decisions delete -i 577 | ``` 578 | 579 | #### Create a bouncer 580 | 581 | 582 | ```bash 583 | cscli bouncers add -o raw 584 | ``` 585 | 586 | With DDEV, an alias is available: 587 | 588 | ```bash 589 | ddev create-bouncer 590 | ``` 591 | 592 | #### Create a watcher 593 | 594 | 595 | ```bash 596 | cscli machines add --password -o raw 597 | ``` 598 | 599 | With DDEV, an alias is available: 600 | 601 | ```bash 602 | ddev create-watcher 603 | ``` 604 | 605 | 606 | ### Use the web container to call LAPI 607 | 608 | Please see the [CrowdSec LAPI documentation](https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI) for an exhaustive list of available calls. 609 | 610 | If you are using DDEV, you can enter the web by running: 611 | 612 | ```bash 613 | ddev exec bash 614 | ```` 615 | 616 | Then, you should use some `curl` calls to contact the LAPI. 617 | 618 | For example, you can get the list of decisions with commands like: 619 | 620 | ```bash 621 | curl -k -H "X-Api-Key: " https://crowdsec:8080/v1/decisions | jq 622 | curl -k -H "X-Api-Key: " https://crowdsec:8080/v1/decisions?ip=1.2.3.4 | jq 623 | curl -k -H "X-Api-Key: " https://crowdsec:8080/v1/decisions/stream?startup=true | jq 624 | curl -k -H "X-Api-Key: " https://crowdsec:8080/v1/decisions/stream | jq 625 | ``` 626 | 627 | 628 | ## Update documentation table of contents 629 | 630 | To update the table of contents in the documentation, you can use [the `doctoc` tool](https://github.com/thlorenz/doctoc). 631 | 632 | First, install it: 633 | 634 | ```bash 635 | npm install -g doctoc 636 | ``` 637 | 638 | Then, run it in the documentation folder: 639 | 640 | ```bash 641 | doctoc docs/* --maxlevel 4 642 | ``` 643 | 644 | 645 | ## Commit message 646 | 647 | In order to have an explicit commit history, we are using some commits message convention with the following format: 648 | 649 | (): 650 | 651 | Allowed `type` are defined below. 652 | 653 | `scope` value intends to clarify which part of the code has been modified. It can be empty or `*` if the change is a 654 | global or difficult to assign to a specific part. 655 | 656 | `subject` describes what has been done using the imperative, present tense. 657 | 658 | Example: 659 | 660 | feat(admin): Add css for admin actions 661 | 662 | 663 | You can use the `commit-msg` git hook that you will find in the `.githooks` folder: 664 | 665 | ``` 666 | cp .githooks/commit-msg .git/hooks/commit-msg 667 | chmod +x .git/hooks/commit-msg 668 | ``` 669 | 670 | ### Allowed message `type` values 671 | 672 | - chore (automatic tasks; no production code change) 673 | - ci (updating continuous integration process; no production code change) 674 | - comment (commenting;no production code change) 675 | - docs (changes to the documentation) 676 | - feat (new feature for the user) 677 | - fix (bug fix for the user) 678 | - refactor (refactoring production code) 679 | - style (formatting; no production code change) 680 | - test (adding missing tests, refactoring tests; no production code change) 681 | 682 | ## Release process 683 | 684 | We are using [semantic versioning](https://semver.org/) to determine a version number. To verify the current tag, 685 | you should run: 686 | ``` 687 | git describe --tags `git rev-list --tags --max-count=1` 688 | ``` 689 | 690 | Before publishing a new release, there are some manual steps to take: 691 | 692 | - Change the version number in the `Constants.php` file 693 | - Update the `CHANGELOG.md` file 694 | 695 | Then, you have to [run the action manually from the GitHub repository](https://github.com/crowdsecurity/cs-standalone-php-bouncer/actions/workflows/release.yml) 696 | 697 | 698 | Alternatively, you could use the [GitHub CLI](https://github.com/cli/cli): 699 | - create a draft release: 700 | ``` 701 | gh workflow run release.yml -f tag_name=vx.y.z -f draft=true 702 | ``` 703 | - publish a prerelease: 704 | ``` 705 | gh workflow run release.yml -f tag_name=vx.y.z -f prerelease=true 706 | ``` 707 | - publish a release: 708 | ``` 709 | gh workflow run release.yml -f tag_name=vx.y.z 710 | ``` 711 | 712 | Note that the GitHub action will fail if the tag `tag_name` already exits. 713 | -------------------------------------------------------------------------------- /docs/INSTALLATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | 3 | # CrowdSec standalone PHP bouncer 4 | 5 | 6 | ## Installation Guide 7 | 8 | **Table of Contents** 9 | 10 | 11 | 12 | - [Requirements](#requirements) 13 | - [Installation](#installation) 14 | - [Prerequisite](#prerequisite) 15 | - [Install composer](#install-composer) 16 | - [Install GIT](#install-git) 17 | - [Install CrowdSec](#install-crowdsec) 18 | - [Server and bouncer setup](#server-and-bouncer-setup) 19 | - [Bouncer sources copy](#bouncer-sources-copy) 20 | - [Files permission](#files-permission) 21 | - [Settings file](#settings-file) 22 | - [`auto_prepend_file` directive](#auto_prepend_file-directive) 23 | - [Stream mode cron task](#stream-mode-cron-task) 24 | - [Cache pruning cron task](#cache-pruning-cron-task) 25 | - [Usage metrics push cron task](#usage-metrics-push-cron-task) 26 | - [Upgrade](#upgrade) 27 | - [Before upgrading](#before-upgrading) 28 | - [Retrieve the last tag](#retrieve-the-last-tag) 29 | - [Checkout to last tag and update sources](#checkout-to-last-tag-and-update-sources) 30 | 31 | 32 | 33 | 34 | ## Requirements 35 | 36 | - PHP >= 7.2.5 37 | - required PHP extensions: `ext-curl`, `ext-gd`, `ext-json`, `ext-mbstring` 38 | 39 | ## Installation 40 | 41 | ### Prerequisite 42 | 43 | #### Install composer 44 | 45 | Please follow [this documentation](https://getcomposer.org/download/) to install composer. 46 | 47 | #### Install GIT 48 | 49 | Please follow [this documentation](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) to install GIT. 50 | 51 | #### Install CrowdSec 52 | 53 | To be able to use this bouncer, the first step is to install [CrowdSec v1](https://doc.crowdsec.net/docs/getting_started/install_crowdsec/). CrowdSec is only in charge of the "detection", and won't block anything on its own. You need to deploy a bouncer to "apply" decisions. 54 | 55 | Please note that first and foremost a CrowdSec agent must be installed on a server that is accessible by this bouncer. 56 | 57 | ### Server and bouncer setup 58 | 59 | Once you set up your server as below, every browser access to a PHP script will be bounced by the standalone bouncer. 60 | 61 | You will have to : 62 | 63 | - retrieve sources of the bouncer in some `/path/to/the/crowdsec-standalone-bouncer` folder 64 | - give the correct permission for the folder that contains the bouncer 65 | - copy the `scripts/settings.php.dist` file to a `scripts/settings.php` file and edit it. 66 | - set an `auto_prepend_file` directive in your PHP setup. 67 | - Optionally, if you want to use the standalone bouncer in stream mode, you will have to set a cron task to refresh 68 | cache periodically. 69 | 70 | #### Bouncer sources copy 71 | 72 | - Create a folder that will contain the project sources: 73 | 74 | ```bash 75 | sudo mkdir -p /var/www/crowdsec-standalone-bouncer 76 | ``` 77 | 78 | We use here `/var/www/crowdsec-standalone-bouncer` but you can choose the path that suits your needs. 79 | 80 | - Change permission to allow composer to be run in this folder. As you should run composer with your user, this 81 | can be done with: 82 | 83 | ```bash 84 | sudo chown -R $(whoami):$(whoami) /var/www/crowdsec-standalone-bouncer 85 | ``` 86 | 87 | - Retrieve the last version of the bouncer: 88 | 89 | ```bash 90 | composer create-project crowdsec/standalone-bouncer /var/www/crowdsec-standalone-bouncer --keep-vcs 91 | ``` 92 | 93 | Note that we have to keep the vcs data as we will use it to update the bouncer when a new version is available. 94 | 95 | #### Files permission 96 | 97 | The owner of the `/var/www/crowdsec-standalone-bouncer` folder should be your web-server owner (e.g. `www-data`) and the group should have the write permission on this folder. 98 | 99 | You can achieve it by running commands like: 100 | 101 | ```bash 102 | sudo chown -R www-data /var/www/crowdsec-standalone-bouncer 103 | sudo chmod g+w /var/www/crowdsec-standalone-bouncer 104 | 105 | ``` 106 | 107 | #### Settings file 108 | 109 | Please copy the `scripts/settings.php.dist` file to a `scripts/settings.php` file and fill the necessary settings in it 110 | (see [Configurations settings](../USER_GUIDE.md#configurations) for more details). 111 | 112 | For a quick start, simply search for `YOUR_BOUNCER_API_KEY` in the `settings.php` file and set the bouncer key. 113 | To obtain a bouncer key, you can run the `cscli` bouncer creation command: 114 | 115 | ``` 116 | sudo cscli bouncers add standalone-bouncer 117 | ``` 118 | 119 | #### `auto_prepend_file` directive 120 | 121 | We will now describe how to set an `auto_prepend_file` directive in order to call the `scripts/bounce.php` for each php access. 122 | 123 | Adding an `auto_prepend_file` directive can be done in different ways: 124 | 125 | ###### `.ini` file 126 | 127 | You should add this line to a `.ini` file : 128 | 129 | auto_prepend_file = /var/www/crowdsec-standalone-bouncer/scripts/bounce.php 130 | 131 | ###### Nginx 132 | 133 | 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: 134 | 135 | ``` 136 | location ~ \.php$ { 137 | ... 138 | ... 139 | ... 140 | fastcgi_param PHP_VALUE "auto_prepend_file=/var/www/crowdsec-standalone-bouncer/scripts/bounce.php"; 141 | } 142 | ``` 143 | 144 | ###### Apache 145 | 146 | If you are using Apache, you should add this line to your `.htaccess` file: 147 | 148 | php_value auto_prepend_file "/var/www/crowdsec-standalone-bouncer/scripts/bounce.php" 149 | 150 | or modify your `Virtual Host` accordingly: 151 | 152 | ``` 153 | 154 | ... 155 | ... 156 | php_value auto_prepend_file "/var/www/crowdsec-standalone-bouncer/scripts/bounce.php" 157 | 158 | 159 | ``` 160 | 161 | #### Stream mode cron task 162 | 163 | To use the stream mode, you first have to set the `stream_mode` setting value to `true` in your `script/settings.php` file. 164 | 165 | Then, you could edit the web server user (e.g. `www-data`) crontab: 166 | 167 | ```shell 168 | sudo -u www-data crontab -e 169 | ``` 170 | 171 | and add the following line 172 | 173 | ```shell 174 | */15 * * * * /usr/bin/php /var/www/crowdsec-standalone-bouncer/scripts/refresh-cache.php 175 | ``` 176 | 177 | In this example, cache is refreshed every 15 minutes, but you can modify the cron expression depending on your needs. 178 | 179 | #### Cache pruning cron task 180 | 181 | If you use the PHP file system as cache, you should prune the cache with a cron job: 182 | 183 | ```shell 184 | sudo -u www-data crontab -e 185 | ``` 186 | 187 | and add the following line 188 | 189 | ```shell 190 | 0 0 * * * /usr/bin/php /var/www/crowdsec-standalone-bouncer/scripts/prune-cache.php 191 | ``` 192 | 193 | In this example, cache is pruned at midnight every day, but you can modify the cron expression depending on your needs. 194 | 195 | #### Usage metrics push cron task 196 | 197 | If you want to push usage metrics to the CrowdSec API, you should add a cron job: 198 | 199 | ```shell 200 | sudo -u www-data crontab -e 201 | ``` 202 | 203 | and add the following line 204 | 205 | ```shell 206 | 0 0 * * * /usr/bin/php /var/www/crowdsec-standalone-bouncer/scripts/push-usage-metrics.php 207 | ``` 208 | 209 | ## Upgrade 210 | 211 | When a new release of the bouncer is available, you may want to update sources to the last version. 212 | 213 | ### Before upgrading 214 | 215 | **Please look at the [CHANGELOG](https://github.com/crowdsecurity/cs-standalone-php-bouncer/blob/main/CHANGELOG.md) before upgrading in order to see the list of changes that could break your application.** 216 | 217 | To limit the risk of breaking your web application during upgrade, you can perform the following actions to disable bouncing: 218 | 219 | - Remove the `auto_prepend_file` directive that point to the `bounce.php` file and restart your web server 220 | - Disable any scheduled cron task linked to bouncer feature 221 | 222 | Alternatively, but a little more risky, you could disable bouncing by editing the `scripts/settings.php` file and set the value `'bouncing_disabled'` for the `'bouncing_level'` parameter. 223 | 224 | Once the update is done, you can reactivate the bounce. You could look at the `/var/www/crowdsec-standalone-bouncer/scripts/.logs` to see if all is working as expected. 225 | 226 | Below are the steps to take to upgrade your current bouncer: 227 | 228 | ### Retrieve the last tag 229 | 230 | As we kept the vcs data during installation (with the `--keep-vcs` flag), we can use git to get the last tagged sources: 231 | 232 | ```bash 233 | cd /var/www/crowdsec-standalone-bouncer 234 | git fetch 235 | ``` 236 | 237 | If you get an error message about "detected dubious ownership", you can run 238 | 239 | ```bash 240 | git config --global --add safe.directory /var/www/crowdsec-standalone-bouncer 241 | ``` 242 | 243 | You should see a list of tags (`vX.Y.Z` format )that have been published after your initial installation. 244 | 245 | ### Checkout to last tag and update sources 246 | 247 | Once you have picked up the `vX.Y.Z` tag you want to try, you could switch to it and update composer dependencies: 248 | 249 | ```bash 250 | git checkout vX.Y.Z && composer update 251 | ``` 252 | 253 | -------------------------------------------------------------------------------- /docs/USER_GUIDE.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | # CrowdSec standalone PHP bouncer 3 | 4 | ## User Guide 5 | 6 | **Table of Contents** 7 | 8 | 9 | 10 | - [Description](#description) 11 | - [Features](#features) 12 | - [Usage](#usage) 13 | - [Configurations](#configurations) 14 | - [Bouncer behavior](#bouncer-behavior) 15 | - [Local API Connection](#local-api-connection) 16 | - [Cache](#cache) 17 | - [Geolocation](#geolocation) 18 | - [Captcha and ban wall settings](#captcha-and-ban-wall-settings) 19 | - [Debug](#debug) 20 | - [Security note](#security-note) 21 | 22 | 23 | 24 | 25 | ## Description 26 | 27 | This project provides a CrowdSec "standalone" bouncer for PHP-based websites. It is intended to be used with [an `auto_prepend_file` directive.](https://www.php.net/manual/en/ini.core.php#ini.auto-prepend-file) 28 | 29 | ## Features 30 | 31 | - CrowdSec Local API Support 32 | - Handle `ip`, `range` and `country` scoped decisions 33 | - `Live mode` or `Stream mode` 34 | - API key or TLS authentication 35 | - AppSec remediation support (only with API key authentication) 36 | 37 | - Handle different tasks: 38 | - Clear or prune cache 39 | - Refresh decisions 40 | - Push usage metrics 41 | 42 | - Large PHP matrix compatibility: from 7.2 to 8.4 43 | - Built-in support for the most known cache systems Redis, Memcached and PhpFiles 44 | - Support IpV4 and Ipv6 (Ipv6 range decisions are yet only supported in `Live mode`) 45 | 46 | 47 | 48 | ## Usage 49 | 50 | When a user is suspected by CrowdSec to be malevolent, the bouncer would either display a captcha to resolve or 51 | simply a page notifying that access is denied. If the user is considered as a clean user, he/she will access the page 52 | as normal. 53 | 54 | A ban wall could look like: 55 | 56 | ![Ban wall](images/screenshots/front-ban.jpg) 57 | 58 | A captcha wall could look like: 59 | 60 | ![Captcha wall](images/screenshots/front-captcha.jpg) 61 | 62 | With the provided standalone bouncer, it is possible to customize all the colors of these pages so that they integrate best with your design. 63 | 64 | On the other hand, all texts are also fully customizable. This will allow you, for example, to present translated pages in your users' language. 65 | 66 | ## Configurations 67 | 68 | Here is the list of available settings that you could define in the `scripts/settings.php` file: 69 | 70 | ### Bouncer behavior 71 | 72 | - `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. 73 | 74 | 75 | - `fallback_remediation`: Select from `bypass` (minimum remediation), `captcha` or `ban` (maximum remediation). Default to 'captcha'. Handle unknown remediations as. 76 | 77 | 78 | - `appsec_fallback_remediation`: Select from `bypass` (minimum remediation), `captcha` (recommended) or `ban` (maximum remediation). 79 | Default to 'captcha'. Will be used as remediation in case of AppSec failure (timeout). 80 | 81 | 82 | - `appsec_max_body_size_kb`: Maximum body size in KB to send to AppSec. Default to 1024 KB. 83 | If exceeded, the action defined by the `appsec_body_size_exceeded_action` setting below will be applied. 84 | 85 | 86 | - `appsec_body_size_exceeded_action`: Action to take when the request body size exceeds the maximum size defined by the `appsec_max_body_size_kb` setting above. 87 | 88 | Possible values are: 89 | 90 | - `headers_only` (recommended and default value): only the headers of the original request are forwarded to AppSec, not the body. 91 | - `allow` (not recommended): the request is considered as safe and a bypass remediation is returned, without calling AppSec. 92 | - `block`: the request is considered as malicious and a ban remediation is returned, without calling AppSec. 93 | 94 | 95 | - `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. 96 | 97 | 98 | - `excluded_uris`: array of URIs that will not be bounced. 99 | 100 | 101 | - `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 user 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. 102 | 103 | - `use_appsec`: true to enable AppSec remediation. Default to false. If you enable this setting, you need to define the `appsec_url` setting below. If true, and if the initial Lapi remediation is a `bypass`, a remediation based on the current request will be retrieved from the AppSec endpoint and will be used as final remediation. This feature is only available if you use `api_key` as `auth_type`. 104 | 105 | ### Local API Connection 106 | 107 | - `auth_type`: Select from `api_key` and `tls`. Choose if you want to use an API-KEY or a TLS (pki) authentification. 108 | TLS authentication is only available if you use CrowdSec agent with a version superior to 1.4.0. 109 | 110 | 111 | - `api_key`: Key generated by the cscli (CrowdSec cli) command like `cscli bouncers add standlone-php-bouncer`. 112 | Only required if you choose `api_key` as `auth_type`. 113 | 114 | 115 | - `tls_cert_path`: absolute path to the bouncer certificate (e.g. pem file). 116 | Only required if you choose `tls` as `auth_type`. 117 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 118 | 119 | 120 | - `tls_key_path`: Absolute path to the bouncer key (e.g. pem file). 121 | Only required if you choose `tls` as `auth_type`. 122 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 123 | 124 | 125 | - `tls_verify_peer`: This option determines whether request handler verifies the authenticity of the peer's certificate. 126 | Only required if you choose `tls` as `auth_type`. 127 | When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. 128 | If `tls_verify_peer` is set to true, request handler verifies whether the certificate is authentic. 129 | This trust is based on a chain of digital signatures, 130 | rooted in certification authority (CA) certificates you supply using the `tls_ca_cert_path` setting below. 131 | 132 | 133 | - `tls_ca_cert_path`: Absolute path to the CA used to process peer verification. 134 | Only required if you choose `tls` as `auth_type` and `tls_verify_peer` is set to true. 135 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 136 | 137 | 138 | - `api_url`: Define the URL to your Local API server, default to `http://localhost:8080`. 139 | 140 | - `appsec_url`: Define the URL to your AppSec server, default to `http://localhost:7422`. Only needed if you use AppSec remediation (see `use_appsec` setting above). 141 | 142 | - `api_timeout`: In seconds. The global timeout when calling Local API. Default to 120 sec. If set to a negative value 143 | or 0, timeout will be unlimited. 144 | 145 | 146 | - `api_connect_timeout`: In seconds. **Only for curl**. The timeout for the connection phase when calling Local 147 | API. Default to 300 sec. If set to a 0, timeout will be unlimited. 148 | 149 | - `appsec_timeout_ms`: In milliseconds. The global timeout when calling AppSec. Default to 400 ms. If set to a negative value or 0, timeout will be unlimited. 150 | 151 | - `appsec_connect_timeout_ms`: In milliseconds. **Only for curl**. The timeout for the connection phase when calling AppSec. Default to 150 ms. If set to a 0, timeout will be unlimited. 152 | 153 | 154 | - `use_curl`: By default, this lib call the REST Local API using `file_get_contents` method (`allow_url_fopen` is required). 155 | You can set `use_curl` to `true` in order to use `cURL` request instead (`ext-curl` is in then required) 156 | 157 | ### Cache 158 | 159 | - `cache_system`: Select from `phpfs` (PHP file cache), `redis` or `memcached`. 160 | 161 | 162 | - `fs_cache_path`: Will be used only if you choose PHP file cache as `cache_system`. 163 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 164 | 165 | 166 | - `redis_dsn`: Will be used only if you choose Redis cache as `cache_system`. 167 | 168 | 169 | - `memcached_dsn`: Will be used only if you choose Memcached as `cache_system`. 170 | 171 | 172 | - `clean_ip_cache_duration`: Set the duration we keep in cache the fact that an IP is clean. In seconds. Defaults to 5. 173 | 174 | 175 | - `bad_ip_cache_duration`: Set the duration we keep in cache the fact that an IP is bad. In seconds. Defaults to 20. 176 | 177 | 178 | - `captcha_cache_duration`: Set the duration we keep in cache the captcha flow variables for an IP. In seconds. 179 | Defaults to 86400.. In seconds. Defaults to 20. 180 | 181 | 182 | ### Geolocation 183 | 184 | - `geolocation`: Settings for geolocation remediation (i.e. country based remediation). 185 | 186 | - `geolocation[enabled]`: true to enable remediation based on country. Default to false. 187 | 188 | - `geolocation[type]`: Geolocation system. Only 'maxmind' is available for the moment. Default to `maxmind`. 189 | 190 | - `geolocation[cache_duration]`: This setting will be used to set the lifetime (in seconds) of a cached country 191 | 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. 192 | 193 | - `geolocation[maxmind]`: MaxMind settings. 194 | 195 | - `geolocation[maxmind][database_type]`: Select from `country` or `city`. Default to `country`. These are the two available MaxMind database types. 196 | 197 | - `geolocation[maxmind][database_path]`: Absolute path to the MaxMind database (e.g. mmdb file) 198 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 199 | 200 | 201 | ### Captcha and ban wall settings 202 | 203 | 204 | - `hide_mentions`: true to hide CrowdSec mentions on ban and captcha walls. 205 | 206 | 207 | - `custom_css`: Custom css directives for ban and captcha walls 208 | 209 | 210 | - `color`: Array of settings for ban and captcha walls colors. 211 | 212 | - `color[text][primary]` 213 | 214 | - `color[text][secondary]` 215 | 216 | - `color[text][button]` 217 | 218 | - `color[text][error_message]` 219 | 220 | - `color[background][page]` 221 | 222 | - `color[background][container]` 223 | 224 | - `color[background][button]` 225 | 226 | - `color[background][button_hover]` 227 | 228 | 229 | - `text`: Array of settings for ban and captcha walls texts. 230 | 231 | - `text[captcha_wall][tab_title]` 232 | 233 | - `text[captcha_wall][title]` 234 | 235 | - `text[captcha_wall][subtitle]` 236 | 237 | - `text[captcha_wall][refresh_image_link]` 238 | 239 | - `text[captcha_wall][captcha_placeholder]` 240 | 241 | - `text[captcha_wall][send_button]` 242 | 243 | - `text[captcha_wall][error_message]` 244 | 245 | - `text[captcha_wall][footer]` 246 | 247 | - `text[ban_wall][tab_title]` 248 | 249 | - `text[ban_wall][title]` 250 | 251 | - `text[ban_wall][subtitle]` 252 | 253 | - `text[ban_wall][footer]` 254 | 255 | 256 | ### Debug 257 | - `debug_mode`: `true` to enable verbose debug log. Default to `false`. 258 | 259 | 260 | - `disable_prod_log`: `true` to disable prod log. Default to `false`. 261 | 262 | 263 | - `log_directory_path`: Absolute path to store log files. 264 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 265 | 266 | 267 | - `display_errors`: true to stop the process and display errors on browser if any. 268 | 269 | 270 | - `forced_test_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used instead of the 271 | real remote ip. 272 | 273 | 274 | - `forced_test_forwarded_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used 275 | instead of the real forwarded ip. If set to `no_forward`, the x-forwarded-for mechanism will not be used at all. 276 | 277 | ### Security note 278 | 279 | Some files should not be publicly accessible because they may contain sensitive data: 280 | 281 | - Setting file `settings.php` 282 | - Log files 283 | - Cache files of the File system cache 284 | - TLS authentication files 285 | - Geolocation database files 286 | 287 | If you define publicly accessible folders in the settings, be sure to add rules to deny access to these files. 288 | 289 | In the following example, we will suppose that you use a folder `crowdsec` with sub-folders `logs`, `cache`, `tls` and `geolocation`. 290 | 291 | If you are using Nginx, you could use the following snippet to modify your website configuration file: 292 | 293 | ```nginx 294 | server { 295 | ... 296 | ... 297 | ... 298 | # Deny all attempts to access some folders of the crowdsec standalone bouncer 299 | location ~ /crowdsec/(settings|logs|cache|tls|geolocation) { 300 | deny all; 301 | } 302 | ... 303 | ... 304 | } 305 | ``` 306 | 307 | If you are using Apache, you could add this kind of directive in a `.htaccess` file: 308 | 309 | ```htaccess 310 | Redirectmatch 403 crowdsec/settings 311 | Redirectmatch 403 crowdsec/logs/ 312 | Redirectmatch 403 crowdsec/cache/ 313 | Redirectmatch 403 crowdsec/tls/ 314 | Redirectmatch 403 crowdsec/geolocation/ 315 | ``` 316 | 317 | **N.B.:** 318 | - There is no need to protect the `cache` folder if you are using Redis or Memcached cache systems. 319 | - There is no need to protect the `logs` folder if you disable debug and prod logging. 320 | - There is no need to protect the `tls` folder if you use Bouncer API key authentication type. 321 | - There is no need to protect the `geolocation` folder if you don't use the geolocation feature. 322 | -------------------------------------------------------------------------------- /docs/images/logo_crowdsec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/docs/images/logo_crowdsec.png -------------------------------------------------------------------------------- /docs/images/screenshots/front-ban.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/docs/images/screenshots/front-ban.jpg -------------------------------------------------------------------------------- /docs/images/screenshots/front-captcha.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/docs/images/screenshots/front-captcha.jpg -------------------------------------------------------------------------------- /patches/gregwar-captcha-constructor.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/Gregwar/Captcha/CaptchaBuilder.php b/src/Gregwar/Captcha/CaptchaBuilder.php 2 | index ab8d93f..67e77cf 100644 3 | --- a/src/Gregwar/Captcha/CaptchaBuilder.php 4 | +++ b/src/Gregwar/Captcha/CaptchaBuilder.php 5 | @@ -133,7 +133,7 @@ class CaptchaBuilder implements CaptchaBuilderInterface 6 | */ 7 | public $tempDir = 'temp/'; 8 | 9 | - public function __construct($phrase = null, PhraseBuilderInterface $builder = null) 10 | + public function __construct($phrase = null, ?PhraseBuilderInterface $builder = null) 11 | { 12 | if ($builder === null) { 13 | $this->builder = new PhraseBuilder; 14 | -------------------------------------------------------------------------------- /scripts/bounce.php: -------------------------------------------------------------------------------- 1 | run(); 17 | } 18 | } catch (Throwable $e) { 19 | $displayErrors = !empty($settings['display_errors']); 20 | if ($displayErrors) { 21 | throw new BouncerException($e->getMessage(), (int) $e->getCode(), $e); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/clear-cache.php: -------------------------------------------------------------------------------- 1 | clearCache(); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/prune-cache.php: -------------------------------------------------------------------------------- 1 | pruneCache(); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/push-usage-metrics.php: -------------------------------------------------------------------------------- 1 | pushUsageMetrics(Constants::BOUNCER_NAME, Constants::VERSION); 15 | } 16 | -------------------------------------------------------------------------------- /scripts/refresh-cache.php: -------------------------------------------------------------------------------- 1 | refreshBlocklistCache(); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/settings.php.dist: -------------------------------------------------------------------------------- 1 | Constants::BOUNCING_LEVEL_NORMAL, 30 | 31 | /** If you use a CDN, a reverse proxy or a load balancer, you can use this setting to whitelist their IPs. 32 | * 33 | * For other IPs, the bouncer will not trust the X-Forwarded-For header. 34 | * 35 | * With the Standalone bouncer, you have to set an array of Ips : ['1.2.3.4', '5.6.7.8'] for example 36 | * The standalone bouncer will automatically transform this array to an array of comparable IPs arrays: 37 | * [['001.002.003.004', '001.002.003.004'], ['005.006.007.008', '005.006.007.008']] 38 | * 39 | * If you use your own bouncer, you should have to set directly an array of comparable IPs arrays 40 | */ 41 | 'trust_ip_forward_array' => [], 42 | /** 43 | * By default, the lib call the REST LAPI using file_get_contents method (allow_url_fopen is required). 44 | * Set 'use_curl' to true in order to use cURL request instead (curl is in then required). 45 | */ 46 | 'use_curl' => false, 47 | /** 48 | * By default, we use the LAPI to get the decision. Set 'use_appsec' to true in order to also the AppSec endpoint, 49 | * in case of no decision from LAPI ("bypass" remediation). 50 | * If true, the AppSec URL must be set in the 'appsec_url' setting below. 51 | */ 52 | 'use_appsec' => false, 53 | /** 54 | * Maximum size of request body (in kilobytes) which, if exceeded, 55 | * will trigger the action defined by the "appsec_body_size_exceeded_action" setting below. 56 | * 57 | * This setting is not required. By default, the maximum body size is set to 1024KB (1MB). 58 | */ 59 | 'appsec_max_body_size_kb' => Constants::APPSEC_DEFAULT_MAX_BODY_SIZE, 60 | /** 61 | * Action to take when the request body size exceeds the maximum size defined by the "appsec_max_body_size_kb" setting above. 62 | * 63 | * Select from: 64 | * - `headers_only` (recommended and default value): only the headers of the original request are forwarded to AppSec, 65 | * not the body. 66 | * - `allow` (not recommended): the request is considered as safe and a bypass remediation is returned, without calling AppSec. 67 | * - `block`: the request is considered as malicious and a ban remediation is returned, without calling AppSec. 68 | */ 69 | 'appsec_body_size_exceeded_action' => Constants::APPSEC_ACTION_HEADERS_ONLY, 70 | /** 71 | * array of URIs that will not be bounced. 72 | */ 73 | 'excluded_uris' => ['/favicon.ico'], 74 | 75 | // Select from 'phpfs' (File system cache), 'redis' or 'memcached'. 76 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 77 | 78 | // Set the duration we keep in cache the captcha flow variables for an IP. In seconds. Defaults to 86400. 79 | 'captcha_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CAPTCHA, 80 | 81 | // true to enable verbose debug log. 82 | 'debug_mode' => false, 83 | // true to disable prod log 84 | 'disable_prod_log' => false, 85 | 86 | /** Absolute path to store log files. 87 | * 88 | * Important note: be sure this path won't be publicly accessible 89 | */ 90 | 'log_directory_path' => __DIR__ . '/.logs', 91 | 92 | // true to stop the process and display errors if any. 93 | 'display_errors' => false, 94 | 95 | /** Only for test or debug purpose. Default to empty. 96 | * 97 | * If not empty, it will be used instead of the real remote ip. 98 | */ 99 | 'forced_test_ip' => '', 100 | 101 | /** Only for test or debug purpose. Default to empty. 102 | * 103 | * If not empty, it will be used instead of the real forwarded ip. 104 | * If set to "no_forward", the x-forwarded-for mechanism will not be used at all. 105 | */ 106 | 'forced_test_forwarded_ip' => '', 107 | 108 | // Settings for ban and captcha walls 109 | 'custom_css' => '', 110 | // true to hide CrowdSec mentions on ban and captcha walls. 111 | 'hide_mentions' => false, 112 | 'color' => [ 113 | 'text' => [ 114 | 'primary' => 'black', 115 | 'secondary' => '#AAA', 116 | 'button' => 'white', 117 | 'error_message' => '#b90000', 118 | ], 119 | 'background' => [ 120 | 'page' => '#eee', 121 | 'container' => 'white', 122 | 'button' => '#626365', 123 | 'button_hover' => '#333', 124 | ], 125 | ], 126 | 'text' => [ 127 | // Settings for captcha wall 128 | 'captcha_wall' => [ 129 | 'tab_title' => 'Oops..', 130 | 'title' => 'Hmm, sorry but...', 131 | 'subtitle' => 'Please complete the security check.', 132 | 'refresh_image_link' => 'refresh image', 133 | 'captcha_placeholder' => 'Type here...', 134 | 'send_button' => 'CONTINUE', 135 | 'error_message' => 'Please try again.', 136 | 'footer' => '', 137 | ], 138 | // Settings for ban wall 139 | 'ban_wall' => [ 140 | 'tab_title' => 'Oops..', 141 | 'title' => '🤭 Oh!', 142 | 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', 143 | 'footer' => '', 144 | ], 145 | ], 146 | 147 | // ============================================================================# 148 | // Client configs 149 | // ============================================================================# 150 | 151 | /** Select from 'api_key' and 'tls'. 152 | * 153 | * Choose if you want to use an API-KEY or a TLS (pki) authentification 154 | * TLS authentication is only available if you use CrowdSec agent with a version superior to 1.4.0 155 | */ 156 | 'auth_type' => Constants::AUTH_KEY, 157 | 158 | /** Absolute path to the bouncer certificate. 159 | * 160 | * Only required if you choose tls as "auth_type" 161 | * Important note: be sure this path won't be publicly accessible 162 | */ 163 | 'tls_cert_path' => '', 164 | 165 | /** Absolute path to the bouncer key. 166 | * 167 | * Only required if you choose tls as "auth_type" 168 | * Important note: be sure this path won't be publicly accessible 169 | */ 170 | 'tls_key_path' => '', 171 | 172 | /** This option determines whether request handler verifies the authenticity of the peer's certificate. 173 | * 174 | * When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. 175 | * If "tls_verify_peer" is set to true, request handler verifies whether the certificate is authentic. 176 | * This trust is based on a chain of digital signatures, 177 | * rooted in certification authority (CA) certificates you supply using the "tls_ca_cert_path" setting below. 178 | */ 179 | 'tls_verify_peer' => true, 180 | 181 | /** Absolute path to the CA used to process peer verification. 182 | * 183 | * Only required if you choose tls as "auth_type" and "tls_verify_peer" is true 184 | * Important note: be sure this path won't be publicly accessible 185 | */ 186 | 'tls_ca_cert_path' => '', 187 | 188 | /** The bouncer api key to access LAPI. 189 | * 190 | * Key generated by the cscli (CrowdSec cli) command like "cscli bouncers add standalone-bouncer" 191 | * Only required if you choose api_key as "auth_type" 192 | */ 193 | 'api_key' => 'YOUR_BOUNCER_API_KEY', 194 | 195 | /** Define the URL to your LAPI server, default to http://localhost:8080. 196 | * 197 | * If you have installed the CrowdSec agent on your server, it should be "http://localhost:8080" or 198 | * "https://localhost:8080" 199 | */ 200 | 'api_url' => LapiConstants::DEFAULT_LAPI_URL, 201 | // Define the AppSec URL of your LAPI server, default to http://localhost:7422. 202 | 'appsec_url' => LapiConstants::DEFAULT_APPSEC_URL, 203 | 204 | // In seconds. The timeout when calling LAPI. Defaults to 120 sec. 205 | 'api_timeout' => Constants::API_TIMEOUT, 206 | 207 | // In seconds. The connection timeout when calling LAPI. Defaults to 300 sec. 208 | 'api_connect_timeout' => Constants::API_CONNECT_TIMEOUT, 209 | 210 | // In milliseconds. The timeout when calling AppSec. Defaults to 400 ms. 211 | 'appsec_timeout_ms' => Constants::APPSEC_TIMEOUT_MS, 212 | 213 | // In milliseconds. The connection timeout when calling APPSEC. Defaults to 150 ms. 214 | 'appsec_connect_timeout_ms' => Constants::APPSEC_CONNECT_TIMEOUT_MS, 215 | 216 | // ============================================================================# 217 | // Remediation engine configs 218 | // ============================================================================# 219 | 220 | /** Select from 'bypass' (minimum remediation), 'captcha' or 'ban' (maximum remediation). 221 | * Default to 'captcha'. 222 | * 223 | * Handle unknown remediations as. 224 | */ 225 | 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, 226 | 227 | /** Select from 'bypass' (minimum remediation), 'captcha' (recommended) or 'ban' (maximum remediation). 228 | * Default to 'captcha'. 229 | * 230 | * Will be used as remediation in case of AppSec failure (timeout)) 231 | */ 232 | 'appsec_fallback_remediation' => Constants::REMEDIATION_CAPTCHA, 233 | 234 | /** 235 | * The `ordered_remediations` setting accepts an array of remediations ordered by priority. 236 | * If there are more than one decision for an IP, remediation with the highest priority will be return. 237 | * The specific remediation `bypass` will always be considered as the lowest priority (there is no need to 238 | * specify it in this setting). 239 | * This setting is not required. If you don't set any value, `['ban']` will be used by default for CAPI remediation 240 | * and `['ban', 'captcha']` for LAPI remediation. 241 | */ 242 | 'ordered_remediations' => [Constants::REMEDIATION_BAN, Constants::REMEDIATION_CAPTCHA], 243 | 244 | /** Will be used only if you choose File system as cache_system. 245 | * 246 | * Important note: be sure this path won't be publicly accessible 247 | */ 248 | 'fs_cache_path' => __DIR__ . '/.cache', 249 | 250 | // Will be used only if you choose Redis cache as cache_system 251 | 'redis_dsn' => 'redis://localhost:6379', 252 | 253 | // Will be used only if you choose Memcached as cache_system 254 | 'memcached_dsn' => 'memcached://localhost:11211', 255 | 256 | // Set the duration we keep in cache the fact that an IP is clean. In seconds. Defaults to 5. 257 | 'clean_ip_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CLEAN_IP, 258 | 259 | // Set the duration we keep in cache the fact that an IP is bad. In seconds. Defaults to 20. 260 | 'bad_ip_cache_duration' => Constants::CACHE_EXPIRATION_FOR_BAD_IP, 261 | 262 | /** true to enable stream mode, false to enable the live mode. Default to false. 263 | * 264 | * By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode 265 | * means that the IP will be checked directly by the CrowdSec API. The rest of your user’s browsing will be 266 | * even more transparent thanks to the fully customizable cache system. 267 | * 268 | * But you can also activate the `stream mode`. This mode allows you to constantly feed the bouncer with the 269 | * malicious IP list via a background task (CRON), making it to be even faster when checking the IP of your 270 | * visitors. Besides, if your site has a lot of unique visitors at the same time, this will not influence the 271 | * traffic to the API of your CrowdSec instance. 272 | */ 273 | 'stream_mode' => false, 274 | 275 | // Settings for geolocation remediation (i.e. country based remediation). 276 | 'geolocation' => [ 277 | // true to enable remediation based on country. Default to false. 278 | 'enabled' => false, 279 | // Geolocation system. Only 'maxmind' is available for the moment. Default to 'maxmind' 280 | 'type' => Constants::GEOLOCATION_TYPE_MAXMIND, 281 | /** 282 | * This setting will be used to set the lifetime (in seconds) of a cached country 283 | * associated to an IP. The purpose is to avoid multiple call to the geolocation system (e.g. maxmind database) 284 | * . Default to 86400. Set 0 to disable caching. 285 | */ 286 | 'cache_duration' => Constants::CACHE_EXPIRATION_FOR_GEO, 287 | // MaxMind settings 288 | 'maxmind' => [ 289 | /**Select from 'country' or 'city'. Default to 'country' 290 | * 291 | * These are the two available MaxMind database types. 292 | */ 293 | 'database_type' => Constants::MAXMIND_COUNTRY, 294 | // Absolute path to the MaxMind database (mmdb file). 295 | // Important note: be sure this path won't be publicly accessible 296 | 'database_path' => '/some/path/GeoLite2-Country.mmdb', 297 | ], 298 | ], 299 | ]; 300 | -------------------------------------------------------------------------------- /src/Bouncer.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?: new FileLog($configs, 'php_standalone_bouncer'); 34 | $configs = $this->handleTrustedIpsConfig($configs); 35 | $configs['user_agent_version'] = Constants::VERSION; 36 | $configs['user_agent_suffix'] = Constants::USER_AGENT_SUFFIX; 37 | $client = $this->handleClient($configs, $this->logger); 38 | $cache = $this->handleCache($configs, $this->logger); 39 | $remediation = new LapiRemediation($configs, $client, $cache, $this->logger); 40 | 41 | parent::__construct($configs, $remediation, $this->logger); 42 | } 43 | 44 | /** 45 | * The current HTTP method. 46 | */ 47 | public function getHttpMethod(): string 48 | { 49 | return $_SERVER['REQUEST_METHOD'] ?? ''; 50 | } 51 | 52 | /** 53 | * @param string $name Ex: "X-Forwarded-For" 54 | */ 55 | public function getHttpRequestHeader(string $name): ?string 56 | { 57 | $headerName = 'HTTP_' . str_replace('-', '_', strtoupper($name)); 58 | if (!\array_key_exists($headerName, $_SERVER)) { 59 | return null; 60 | } 61 | 62 | return is_string($_SERVER[$headerName]) ? $_SERVER[$headerName] : null; 63 | } 64 | 65 | /** 66 | * Get the value of a posted field. 67 | */ 68 | public function getPostedVariable(string $name): ?string 69 | { 70 | if (!isset($_POST[$name])) { 71 | return null; 72 | } 73 | 74 | return is_string($_POST[$name]) ? $_POST[$name] : null; 75 | } 76 | 77 | /** 78 | * @return string The current IP, even if it's the IP of a proxy 79 | */ 80 | public function getRemoteIp(): string 81 | { 82 | return $_SERVER['REMOTE_ADDR'] ?? ''; 83 | } 84 | 85 | /** 86 | * @SuppressWarnings(PHPMD.ElseExpression) 87 | */ 88 | public function getRequestHeaders(): array 89 | { 90 | $allHeaders = []; 91 | 92 | if (function_exists('getallheaders')) { 93 | // @codeCoverageIgnoreStart 94 | $allHeaders = getallheaders(); 95 | // @codeCoverageIgnoreEnd 96 | } else { 97 | $this->logger->warning( 98 | 'getallheaders() function is not available', 99 | [ 100 | 'type' => 'GETALLHEADERS_NOT_AVAILABLE', 101 | 'message' => 'Resulting headers may not be accurate', 102 | ] 103 | ); 104 | foreach ($_SERVER as $name => $value) { 105 | if ('HTTP_' == substr($name, 0, 5)) { 106 | $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5))))); 107 | $allHeaders[$name] = $value; 108 | } elseif ('CONTENT_TYPE' == $name) { 109 | $allHeaders['Content-Type'] = $value; 110 | } 111 | } 112 | } 113 | 114 | return $allHeaders; 115 | } 116 | 117 | /** 118 | * Get current request host. 119 | */ 120 | public function getRequestHost(): string 121 | { 122 | return $_SERVER['HTTP_HOST'] ?? ''; 123 | } 124 | 125 | public function getRequestRawBody(): string 126 | { 127 | return $this->buildRequestRawBody(fopen('php://input', 'rb')); 128 | } 129 | 130 | /** 131 | * The current URI. 132 | */ 133 | public function getRequestUri(): string 134 | { 135 | return $_SERVER['REQUEST_URI'] ?? ''; 136 | } 137 | 138 | /** 139 | * Get current request user agent. 140 | */ 141 | public function getRequestUserAgent(): string 142 | { 143 | return $_SERVER['HTTP_USER_AGENT'] ?? ''; 144 | } 145 | 146 | /** 147 | * The Standalone bouncer "trust_ip_forward_array" config accepts an array of IPs. 148 | * This method will return array of comparable IPs array. 149 | * 150 | * @param array $configs // ['1.2.3.4'] 151 | * 152 | * @return array // [['001.002.003.004', '001.002.003.004']] 153 | * 154 | * @throws BouncerException 155 | */ 156 | private function handleTrustedIpsConfig(array $configs): array 157 | { 158 | // Convert array of string to array of array with comparable IPs 159 | if (isset($configs['trust_ip_forward_array']) && \is_array($configs['trust_ip_forward_array'])) { 160 | $forwardConfigs = $configs['trust_ip_forward_array']; 161 | $finalForwardConfigs = []; 162 | foreach ($forwardConfigs as $forwardConfig) { 163 | if (!\is_string($forwardConfig)) { 164 | throw new BouncerException('\'trust_ip_forward_array\' config must be an array of string'); 165 | } 166 | $parsedString = Factory::parseAddressString($forwardConfig, 3); 167 | if (!empty($parsedString)) { 168 | $comparableValue = $parsedString->getComparableString(); 169 | $finalForwardConfigs[] = [$comparableValue, $comparableValue]; 170 | } 171 | } 172 | $configs['trust_ip_forward_array'] = $finalForwardConfigs; 173 | } 174 | 175 | return $configs; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Constants.php: -------------------------------------------------------------------------------- 1 | useTls = (string) getenv('BOUNCER_TLS_PATH'); 51 | $this->useCurl = (bool) getenv('USE_CURL'); 52 | $this->logger = TestHelpers::createLogger(); 53 | $this->root = vfsStream::setup('/tmp'); 54 | 55 | $bouncerConfigs = [ 56 | 'auth_type' => $this->useTls ? \CrowdSec\LapiClient\Constants::AUTH_TLS : Constants::AUTH_KEY, 57 | 'api_key' => getenv('BOUNCER_KEY'), 58 | 'api_url' => getenv('LAPI_URL'), 59 | 'use_curl' => $this->useCurl, 60 | 'user_agent_suffix' => 'testphpbouncer', 61 | ]; 62 | if ($this->useTls) { 63 | $this->addTlsConfig($bouncerConfigs, $this->useTls); 64 | } 65 | 66 | $this->configs = $bouncerConfigs; 67 | $this->watcherClient = new WatcherClient($this->configs); 68 | // Delete all decisions 69 | $this->watcherClient->deleteAllDecisions(); 70 | } 71 | 72 | public function maxmindConfigProvider(): array 73 | { 74 | return TestHelpers::maxmindConfigProvider(); 75 | } 76 | 77 | private function handleMaxMindConfig(array $maxmindConfig): array 78 | { 79 | // Check if MaxMind database exist 80 | if (!file_exists($maxmindConfig['database_path'])) { 81 | $this->fail('There must be a MaxMind Database here: ' . $maxmindConfig['database_path']); 82 | } 83 | 84 | return [ 85 | 'cache_duration' => 0, 86 | 'enabled' => true, 87 | 'type' => 'maxmind', 88 | 'maxmind' => [ 89 | 'database_type' => $maxmindConfig['database_type'], 90 | 'database_path' => $maxmindConfig['database_path'], 91 | ], 92 | ]; 93 | } 94 | 95 | /** 96 | * @dataProvider maxmindConfigProvider 97 | * 98 | * @throws \Symfony\Component\Cache\Exception\CacheException 99 | * @throws \Psr\Cache\InvalidArgumentException 100 | */ 101 | public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindConfig): void 102 | { 103 | // Init context 104 | $this->watcherClient->setInitialState(); 105 | 106 | // Init bouncer 107 | $geolocationConfig = $this->handleMaxMindConfig($maxmindConfig); 108 | $bouncerConfigs = [ 109 | 'api_key' => TestHelpers::getBouncerKey(), 110 | 'api_url' => TestHelpers::getLapiUrl(), 111 | 'geolocation' => $geolocationConfig, 112 | 'use_curl' => $this->useCurl, 113 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 114 | 'fs_cache_path' => $this->root->url(), 115 | 'stream_mode' => false, 116 | ]; 117 | 118 | $bouncer = new Bouncer($bouncerConfigs, $this->logger); 119 | 120 | $bouncer->clearCache(); 121 | 122 | $this->assertEquals( 123 | 'captcha', 124 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 125 | 'Get decisions for a clean IP but bad country (captcha)' 126 | ); 127 | 128 | $this->assertEquals( 129 | 'bypass', 130 | $bouncer->getRemediationForIp(TestHelpers::IP_FRANCE)['remediation'], 131 | 'Get decisions for a clean IP and clean country' 132 | ); 133 | 134 | // Disable Geolocation feature 135 | $geolocationConfig['enabled'] = false; 136 | $bouncerConfigs['geolocation'] = $geolocationConfig; 137 | $bouncer = new Bouncer($bouncerConfigs, $this->logger); 138 | $bouncer->clearCache(); 139 | 140 | $this->assertEquals( 141 | 'bypass', 142 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 143 | 'Get decisions for a clean IP and bad country but with geolocation disabled' 144 | ); 145 | 146 | // Enable again geolocation and change testing conditions 147 | $this->watcherClient->setSecondState(); 148 | $geolocationConfig['enabled'] = true; 149 | $bouncerConfigs['geolocation'] = $geolocationConfig; 150 | $bouncer = new Bouncer($bouncerConfigs, $this->logger); 151 | $bouncer->clearCache(); 152 | 153 | $this->assertEquals( 154 | 'ban', 155 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 156 | 'Get decisions for a bad IP (ban) and bad country (captcha)' 157 | ); 158 | 159 | $this->assertEquals( 160 | 'ban', 161 | $bouncer->getRemediationForIp(TestHelpers::IP_FRANCE)['remediation'], 162 | 'Get decisions for a bad IP (ban) and clean country' 163 | ); 164 | } 165 | 166 | /** 167 | * @group integration 168 | * 169 | * @dataProvider maxmindConfigProvider 170 | * 171 | * @throws \Symfony\Component\Cache\Exception\CacheException|\Psr\Cache\InvalidArgumentException 172 | */ 173 | public function testCanVerifyIpAndCountryWithMaxmindInStreamMode(array $maxmindConfig): void 174 | { 175 | // Init context 176 | $this->watcherClient->setInitialState(); 177 | // Init bouncer 178 | $geolocationConfig = $this->handleMaxMindConfig($maxmindConfig); 179 | $bouncerConfigs = [ 180 | 'api_key' => TestHelpers::getBouncerKey(), 181 | 'api_url' => TestHelpers::getLapiUrl(), 182 | 'stream_mode' => true, 183 | 'geolocation' => $geolocationConfig, 184 | 'use_curl' => $this->useCurl, 185 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 186 | 'fs_cache_path' => $this->root->url(), 187 | ]; 188 | 189 | $bouncer = new Bouncer($bouncerConfigs, $this->logger); 190 | $cacheAdapter = $bouncer->getRemediationEngine()->getCacheStorage(); 191 | $cacheAdapter->clear(); 192 | 193 | // Warm BlockList cache up 194 | $bouncer->refreshBlocklistCache(); 195 | 196 | $this->logger->debug('', ['message' => 'Refresh the cache just after the warm up. Nothing should append.']); 197 | $bouncer->refreshBlocklistCache(); 198 | 199 | $this->assertEquals( 200 | 'captcha', 201 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 202 | 'Should captcha a clean IP coming from a bad country (captcha)' 203 | ); 204 | 205 | // Add and remove decision 206 | $this->watcherClient->setSecondState(); 207 | 208 | $this->assertEquals( 209 | 'captcha', 210 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 211 | 'Should still captcha a bad IP (ban) coming from a bad country (captcha) as cache has not been refreshed' 212 | ); 213 | 214 | // Pull updates 215 | $bouncer->refreshBlocklistCache(); 216 | 217 | $this->assertEquals( 218 | 'ban', 219 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 220 | 'The new decision should now be added, so the previously captcha IP should now be ban' 221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /tests/Integration/StandaloneBouncerNoResponse.php: -------------------------------------------------------------------------------- 1 | setFormatter(new LineFormatter("%datetime%|%level%|%context%\n")); 33 | $log->pushHandler($handler); 34 | 35 | return $log; 36 | } 37 | 38 | /** 39 | * @throws ErrorException 40 | * @throws CacheException 41 | */ 42 | public static function cacheAdapterConfigProvider(): array 43 | { 44 | return [ 45 | 'PhpFilesAdapter' => [Constants::CACHE_SYSTEM_PHPFS, 'PhpFilesAdapter'], 46 | 'RedisAdapter' => [Constants::CACHE_SYSTEM_REDIS, 'RedisAdapter'], 47 | 'MemcachedAdapter' => [Constants::CACHE_SYSTEM_MEMCACHED, 'MemcachedAdapter'], 48 | ]; 49 | } 50 | 51 | public static function maxmindConfigProvider(): array 52 | { 53 | return [ 54 | 'country database' => [[ 55 | 'database_type' => 'country', 56 | 'database_path' => __DIR__ . '/../GeoLite2-Country.mmdb', 57 | ]], 58 | 'city database' => [[ 59 | 'database_type' => 'city', 60 | 'database_path' => __DIR__ . '/../GeoLite2-City.mmdb', 61 | ]], 62 | ]; 63 | } 64 | 65 | public static function getLapiUrl(): string 66 | { 67 | return getenv('LAPI_URL'); 68 | } 69 | 70 | public static function getAppSecUrl(): string 71 | { 72 | return getenv('APPSEC_URL'); 73 | } 74 | 75 | public static function getBouncerKey(): string 76 | { 77 | if ($bouncerKey = getenv('BOUNCER_KEY')) { 78 | return $bouncerKey; 79 | } 80 | 81 | return ''; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Integration/WatcherClient.php: -------------------------------------------------------------------------------- 1 | configs = $configs; 34 | $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; 35 | $agentTlsPath = getenv('AGENT_TLS_PATH'); 36 | if ($agentTlsPath) { 37 | $this->configs['auth_type'] = Constants::AUTH_TLS; 38 | $this->configs['tls_cert_path'] = $agentTlsPath . '/agent.pem'; 39 | $this->configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; 40 | $this->configs['tls_verify_peer'] = false; 41 | } 42 | 43 | parent::__construct($this->configs); 44 | } 45 | 46 | /** 47 | * Make a request. 48 | * 49 | * @throws ClientException 50 | */ 51 | private function manageRequest( 52 | string $method, 53 | string $endpoint, 54 | array $parameters = [] 55 | ): array { 56 | $this->logger->debug('', [ 57 | 'type' => 'WATCHER_CLIENT_REQUEST', 58 | 'method' => $method, 59 | 'endpoint' => $endpoint, 60 | 'parameters' => $parameters, 61 | ]); 62 | 63 | return $this->request($method, $endpoint, $parameters, $this->headers); 64 | } 65 | 66 | /** Set the initial watcher state */ 67 | public function setInitialState(): void 68 | { 69 | $this->deleteAllDecisions(); 70 | $now = new \DateTime(); 71 | $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, 'captcha'); 72 | $this->addDecision($now, '24h', self::HOURS24, TestHelpers::BAD_IP . '/' . TestHelpers::IP_RANGE, 'ban'); 73 | $this->addDecision($now, '24h', '+24 hours', TestHelpers::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); 74 | } 75 | 76 | /** Set the second watcher state */ 77 | public function setSecondState(): void 78 | { 79 | $this->logger->info('', ['message' => 'Set "second" state']); 80 | $this->deleteAllDecisions(); 81 | $now = new \DateTime(); 82 | $this->addDecision($now, '36h', '+36 hours', TestHelpers::NEWLY_BAD_IP, 'ban'); 83 | $this->addDecision( 84 | $now, 85 | '48h', 86 | '+48 hours', 87 | TestHelpers::NEWLY_BAD_IP . '/' . TestHelpers::IP_RANGE, 88 | 'captcha' 89 | ); 90 | $this->addDecision($now, '24h', self::HOURS24, TestHelpers::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); 91 | $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_JAPAN, 'ban'); 92 | $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_FRANCE, 'ban'); 93 | } 94 | 95 | public function setSimpleDecision(string $type = 'ban'): void 96 | { 97 | $this->deleteAllDecisions(); 98 | $now = new \DateTime(); 99 | $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, $type); 100 | } 101 | 102 | /** 103 | * Ensure we retrieved a JWT to connect the API. 104 | */ 105 | private function ensureLogin(): void 106 | { 107 | if (!$this->token) { 108 | $data = [ 109 | 'scenarios' => [], 110 | 'machine_id' => self::WATCHER_LOGIN, 111 | 'password' => self::WATCHER_PASSWORD, 112 | ]; 113 | if (getenv('AGENT_TLS_PATH')) { 114 | unset($data['password'], $data['machine_id']); 115 | } 116 | $credentials = $this->manageRequest( 117 | 'POST', 118 | self::WATCHER_LOGIN_ENDPOINT, 119 | $data 120 | ); 121 | 122 | $this->token = $credentials['token']; 123 | $this->headers['Authorization'] = 'Bearer ' . $this->token; 124 | } 125 | } 126 | 127 | public function deleteAllDecisions(): void 128 | { 129 | // Delete all existing decisions. 130 | $this->ensureLogin(); 131 | 132 | $this->manageRequest( 133 | 'DELETE', 134 | self::WATCHER_DECISIONS_ENDPOINT, 135 | [] 136 | ); 137 | } 138 | 139 | protected function getFinalScope($scope, $value) 140 | { 141 | $scope = (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : 142 | $scope; 143 | 144 | /** 145 | * Must use capital first letter as the crowdsec agent seems to query with first capital letter 146 | * during getStreamDecisions. 147 | * 148 | * @see https://github.com/crowdsecurity/crowdsec/blob/ae6bf3949578a5f3aa8ec415e452f15b404ba5af/pkg/database/decisions.go#L56 149 | */ 150 | return ucfirst($scope); 151 | } 152 | 153 | public function addDecision( 154 | \DateTime $now, 155 | string $durationString, 156 | string $dateTimeDurationString, 157 | string $value, 158 | string $type, 159 | string $scope = Constants::SCOPE_IP 160 | ) { 161 | $stopAt = (clone $now)->modify($dateTimeDurationString)->format('Y-m-d\TH:i:s.000\Z'); 162 | $startAt = $now->format('Y-m-d\TH:i:s.000\Z'); 163 | 164 | $body = [ 165 | 'capacity' => 0, 166 | 'decisions' => [ 167 | [ 168 | 'duration' => $durationString, 169 | 'origin' => 'cscli', 170 | 'scenario' => $type . ' for scope/value (' . $scope . '/' . $value . ') for ' 171 | . $durationString . ' for PHPUnit tests', 172 | 'scope' => $this->getFinalScope($scope, $value), 173 | 'type' => $type, 174 | 'value' => $value, 175 | ], 176 | ], 177 | 'events' => [ 178 | ], 179 | 'events_count' => 1, 180 | 'labels' => null, 181 | 'leakspeed' => '0', 182 | 'message' => 'setup for PHPUnit tests', 183 | 'scenario' => 'setup for PHPUnit tests', 184 | 'scenario_hash' => '', 185 | 'scenario_version' => '', 186 | 'simulated' => false, 187 | 'source' => [ 188 | 'scope' => $this->getFinalScope($scope, $value), 189 | 'value' => $value, 190 | ], 191 | 'start_at' => $startAt, 192 | 'stop_at' => $stopAt, 193 | ]; 194 | 195 | $result = $this->manageRequest( 196 | 'POST', 197 | self::WATCHER_ALERT_ENDPOINT, 198 | [$body] 199 | ); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/PHPUnitUtil.php: -------------------------------------------------------------------------------- 1 | getMethod($name); 25 | $method->setAccessible(true); 26 | 27 | return $method->invokeArgs($obj, $args); 28 | } 29 | 30 | public static function getPHPUnitVersion(): string 31 | { 32 | return Version::id(); 33 | } 34 | 35 | public static function assertRegExp($testCase, $pattern, $string, $message = '') 36 | { 37 | if (version_compare(self::getPHPUnitVersion(), '9.0', '>=')) { 38 | $testCase->assertMatchesRegularExpression($pattern, $string, $message); 39 | } else { 40 | $testCase->assertRegExp($pattern, $string, $message); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Unit/BouncerTest.php: -------------------------------------------------------------------------------- 1 | false, 63 | 'debug_mode' => true, 64 | 'disable_prod_log' => false, 65 | 'log_directory_path' => __DIR__ . '/.logs', 66 | 'display_errors' => true, 67 | 'forced_test_ip' => '', 68 | 'forced_test_forwarded_ip' => '', 69 | 'bouncing_level' => Constants::BOUNCING_LEVEL_NORMAL, 70 | 'trust_ip_forward_array' => ['5.6.7.8'], 71 | 'excluded_uris' => [self::EXCLUDED_URI], 72 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 73 | 'captcha_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CAPTCHA, 74 | 'custom_css' => '', 75 | 'hide_mentions' => false, 76 | 'color' => [ 77 | 'text' => [ 78 | 'primary' => 'black', 79 | 'secondary' => '#AAA', 80 | 'button' => 'white', 81 | 'error_message' => '#b90000', 82 | ], 83 | 'background' => [ 84 | 'page' => '#eee', 85 | 'container' => 'white', 86 | 'button' => '#626365', 87 | 'button_hover' => '#333', 88 | ], 89 | ], 90 | 'text' => [ 91 | 'captcha_wall' => [ 92 | 'tab_title' => 'Oops..', 93 | 'title' => 'Hmm, sorry but...', 94 | 'subtitle' => 'Please complete the security check.', 95 | 'refresh_image_link' => 'refresh image', 96 | 'captcha_placeholder' => 'Type here...', 97 | 'send_button' => 'CONTINUE', 98 | 'error_message' => 'Please try again.', 99 | 'footer' => '', 100 | ], 101 | 'ban_wall' => [ 102 | 'tab_title' => 'Oops..', 103 | 'title' => '🤭 Oh!', 104 | 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', 105 | 'footer' => '', 106 | ], 107 | ], 108 | // ============================================================================# 109 | // Client configs 110 | // ============================================================================# 111 | 'auth_type' => Constants::AUTH_KEY, 112 | 'tls_cert_path' => '', 113 | 'tls_key_path' => '', 114 | 'tls_verify_peer' => true, 115 | 'tls_ca_cert_path' => '', 116 | 'api_key' => 'unit-test', 117 | 'api_url' => LapiConstants::DEFAULT_LAPI_URL, 118 | 'appsec_url' => LapiConstants::DEFAULT_APPSEC_URL, 119 | 'use_appsec' => false, 120 | 'api_timeout' => 1, 121 | // ============================================================================# 122 | // Remediation engine configs 123 | // ============================================================================# 124 | 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, 125 | 'ordered_remediations' => [Constants::REMEDIATION_BAN, Constants::REMEDIATION_CAPTCHA], 126 | 'fs_cache_path' => __DIR__ . '/.cache', 127 | 'redis_dsn' => 'redis://localhost:6379', 128 | 'memcached_dsn' => 'memcached://localhost:11211', 129 | 'clean_ip_cache_duration' => 1, 130 | 'bad_ip_cache_duration' => 1, 131 | 'stream_mode' => false, 132 | 'geolocation' => [ 133 | 'enabled' => false, 134 | 'type' => Constants::GEOLOCATION_TYPE_MAXMIND, 135 | 'cache_duration' => Constants::CACHE_EXPIRATION_FOR_GEO, 136 | 'maxmind' => [ 137 | 'database_type' => Constants::MAXMIND_COUNTRY, 138 | 'database_path' => '/some/path/GeoLite2-Country.mmdb', 139 | ], 140 | ], 141 | ]; 142 | 143 | protected function setUp(): void 144 | { 145 | unset($_SERVER['REMOTE_ADDR']); 146 | $this->root = vfsStream::setup('/tmp'); 147 | $this->configs['log_directory_path'] = $this->root->url(); 148 | $this->configs['fs_cache_path'] = $this->root->url() . '/.cache'; 149 | $currentDate = date('Y-m-d'); 150 | $this->debugFile = 'debug-' . $currentDate . '.log'; 151 | $this->prodFile = 'prod-' . $currentDate . '.log'; 152 | $this->logger = new FileLog(['log_directory_path' => $this->root->url(), 'debug_mode' => true]); 153 | } 154 | 155 | public function testPrivateAndProtectedMethods() 156 | { 157 | if (PHP_VERSION_ID >= 80400) { 158 | // Retrieve the current error reporting level 159 | $originalErrorReporting = error_reporting(); 160 | // Suppress deprecated warnings temporarily 161 | // We do this because of 162 | // Deprecated: Gregwar\Captcha\CaptchaBuilder::__construct(): Implicitly marking parameter $builder as nullable 163 | // is deprecated, the explicit nullable type must be used instead 164 | error_reporting($originalErrorReporting & ~E_DEPRECATED); 165 | } 166 | $bouncer = new Bouncer($this->configs); 167 | 168 | // checkCaptcha 169 | $result = PHPUnitUtil::callMethod( 170 | $bouncer, 171 | 'checkCaptcha', 172 | ['test1', 'test2', '5.6.7.8'] 173 | ); 174 | $this->assertEquals(false, $result, 'Captcha should be marked as not resolved'); 175 | 176 | $result = PHPUnitUtil::callMethod( 177 | $bouncer, 178 | 'checkCaptcha', 179 | ['test1', 'test1', '5.6.7.8'] 180 | ); 181 | $this->assertEquals(true, $result, 'Captcha should be marked as resolved'); 182 | 183 | $result = PHPUnitUtil::callMethod( 184 | $bouncer, 185 | 'checkCaptcha', 186 | ['test1', 'TEST1', '5.6.7.8'] 187 | ); 188 | $this->assertEquals(true, $result, 'Captcha should be marked as resolved even for case non matching'); 189 | 190 | $result = PHPUnitUtil::callMethod( 191 | $bouncer, 192 | 'checkCaptcha', 193 | ['001', 'ool', '5.6.7.8'] 194 | ); 195 | $this->assertEquals(true, $result, 'Captcha should be marked as resolved even for some similar chars'); 196 | 197 | // buildCaptchaCouple 198 | $result = PHPUnitUtil::callMethod( 199 | $bouncer, 200 | 'buildCaptchaCouple', 201 | [] 202 | ); 203 | 204 | $this->assertArrayHasKey('phrase', $result, 'Captcha couple should have a phrase'); 205 | $this->assertArrayHasKey('inlineImage', $result, 'Captcha couple should have a inlineImage'); 206 | 207 | $this->assertIsString($result['phrase'], 'Captcha phrase should be ok'); 208 | $this->assertEquals(5, strlen($result['phrase']), 'Captcha phrase should be of length 5'); 209 | 210 | $this->assertStringStartsWith('data:image/jpeg;base64', $result['inlineImage'], 'Captcha image should be ok'); 211 | 212 | // getCache 213 | $result = PHPUnitUtil::callMethod( 214 | $bouncer, 215 | 'getCache', 216 | [] 217 | ); 218 | 219 | $this->assertEquals('CrowdSec\RemediationEngine\CacheStorage\PhpFiles', \get_class($result), 'Get cache should return remediation cache'); 220 | // getBanHtml 221 | $this->configs = array_merge($this->configs, [ 222 | 'text' => [ 223 | 'ban_wall' => [ 224 | 'title' => 'BAN TEST TITLE', 225 | ], 226 | ], 227 | ]); 228 | $bouncer = new Bouncer($this->configs); 229 | 230 | $result = PHPUnitUtil::callMethod( 231 | $bouncer, 232 | 'getBanHtml', 233 | [] 234 | ); 235 | $this->assertStringContainsString('

BAN TEST TITLE

', $result, 'Ban rendering should be as expected'); 236 | 237 | // getCaptchaHtml 238 | $this->configs = array_merge($this->configs, [ 239 | 'text' => [ 240 | 'captcha_wall' => [ 241 | 'title' => 'CAPTCHA TEST TITLE', 242 | ], 243 | ], 244 | ]); 245 | $bouncer = new Bouncer($this->configs); 246 | $result = PHPUnitUtil::callMethod( 247 | $bouncer, 248 | 'getCaptchaHtml', 249 | [false, 'fake-inline-image', 'fake-url'] 250 | ); 251 | $this->assertStringContainsString('CAPTCHA TEST TITLE', $result, 'Captcha rendering should be as expected'); 252 | $this->assertStringNotContainsString('

', $result, 'Should be no error message'); 253 | 254 | $result = PHPUnitUtil::callMethod( 255 | $bouncer, 256 | 'getCaptchaHtml', 257 | [true, 'fake-inline-image', 'fake-url'] 258 | ); 259 | $this->assertStringContainsString('

', $result, 'Should be no error message'); 260 | 261 | // shouldNotCheckResolution 262 | $result = PHPUnitUtil::callMethod( 263 | $bouncer, 264 | 'shouldNotCheckResolution', 265 | [['has_to_be_resolved' => false]] 266 | ); 267 | 268 | $this->assertEquals(true, $result, 'No check if no flagged has_to_be_resolved'); 269 | 270 | $_SERVER['REQUEST_METHOD'] = 'GET'; 271 | $result = PHPUnitUtil::callMethod( 272 | $bouncer, 273 | 'shouldNotCheckResolution', 274 | [['has_to_be_resolved' => true]] 275 | ); 276 | 277 | $this->assertEquals(true, $result, 'No check if method is not POST'); 278 | 279 | $_SERVER['REQUEST_METHOD'] = 'POST'; 280 | $_POST['crowdsec_captcha'] = 'test'; 281 | $result = PHPUnitUtil::callMethod( 282 | $bouncer, 283 | 'shouldNotCheckResolution', 284 | [['has_to_be_resolved' => true]] 285 | ); 286 | 287 | $this->assertEquals(false, $result, 'Check if method is POST and posted crowdsec_captcha'); 288 | unset($_POST['crowdsec_captcha']); 289 | $result = PHPUnitUtil::callMethod( 290 | $bouncer, 291 | 'shouldNotCheckResolution', 292 | [['has_to_be_resolved' => true]] 293 | ); 294 | 295 | $this->assertEquals(true, $result, 'No check if method is POST and no posted crowdsec_captcha'); 296 | 297 | // shouldTrustXforwardedFor 298 | unset($_POST['crowdsec_captcha']); 299 | $result = PHPUnitUtil::callMethod( 300 | $bouncer, 301 | 'shouldTrustXforwardedFor', 302 | ['not-an-ip'] 303 | ); 304 | $this->assertEquals(false, $result, 'Should return false if ip is invalid'); 305 | 306 | // handleTrustedIpsConfig 307 | $result = PHPUnitUtil::callMethod( 308 | $bouncer, 309 | 'handleTrustedIpsConfig', 310 | [['trust_ip_forward_array' => ['1.2.3.4']]] 311 | ); 312 | 313 | $this->assertEquals(['trust_ip_forward_array' => [['001.002.003.004', '001.002.003.004']]], $result, 'Should 314 | return comparable array'); 315 | 316 | $error = ''; 317 | 318 | try { 319 | PHPUnitUtil::callMethod( 320 | $bouncer, 321 | 'handleTrustedIpsConfig', 322 | [['trust_ip_forward_array' => [['001.002.003.004', '001.002.003.004']]]] 323 | ); 324 | } catch (BouncerException $e) { 325 | $error = $e->getMessage(); 326 | } 327 | 328 | PHPUnitUtil::assertRegExp( 329 | $this, 330 | '/config must be an array of string/', 331 | $error, 332 | 'Should have throw an error' 333 | ); 334 | if (PHP_VERSION_ID >= 80400 && isset($originalErrorReporting)) { 335 | // Restore the original error reporting level 336 | error_reporting($originalErrorReporting); 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /tests/end-to-end/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /tests/end-to-end/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | // Required for certain syntax usages 4 | "ecmaVersion": 2018 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "airbnb-base", 9 | "plugin:prettier/recommended" 10 | ], 11 | "rules": { 12 | "no-unused-vars": 1, 13 | "no-underscore-dangle": 0, 14 | "import/no-dynamic-require": 0, 15 | "no-console": [1, { "allow": ["warn", "error", "debug"] }] 16 | }, 17 | "env": { 18 | "jest": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/end-to-end/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cookies.json 3 | .test-results*.json 4 | *.lock 5 | *.log 6 | *.jpg 7 | !assets/*.jpg 8 | -------------------------------------------------------------------------------- /tests/end-to-end/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "tabWidth": 4 6 | } 7 | -------------------------------------------------------------------------------- /tests/end-to-end/CustomEnvironment.js: -------------------------------------------------------------------------------- 1 | const PlaywrightEnvironment = 2 | require("jest-playwright-preset/lib/PlaywrightEnvironment").default; 3 | 4 | class CustomEnvironment extends PlaywrightEnvironment { 5 | async handleTestEvent(event) { 6 | if (process.env.FAIL_FAST) { 7 | if ( 8 | event.name === "hook_failure" || 9 | event.name === "test_fn_failure" 10 | ) { 11 | this.failedTest = true; 12 | const buffer = await this.global.page.screenshot({ 13 | path: "screenshot.jpg", 14 | type: "jpeg", 15 | quality: 20, 16 | }); 17 | console.debug("Screenshot:", buffer.toString("base64")); 18 | } else if (this.failedTest && event.name === "test_start") { 19 | // eslint-disable-next-line no-param-reassign 20 | event.test.mode = "skip"; 21 | } 22 | } 23 | 24 | if (super.handleTestEvent) { 25 | await super.handleTestEvent(event); 26 | } 27 | } 28 | } 29 | 30 | module.exports = CustomEnvironment; 31 | -------------------------------------------------------------------------------- /tests/end-to-end/__scripts__/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run test suite 3 | # Usage: ./run-tests.sh 4 | # type : host, docker or ci (default: host) 5 | # file-list : a list of test files (default: empty so it will run all the tests) 6 | # Example: ./run-tests.sh docker "./__tests__/1-live-mode.js" 7 | 8 | YELLOW='\033[33m' 9 | RESET='\033[0m' 10 | if ! ddev --version >/dev/null 2>&1; then 11 | printf "%bDdev is required for this script. Please see docs/ddev.md.%b\n" "${YELLOW}" "${RESET}" 12 | exit 1 13 | fi 14 | 15 | 16 | TYPE=${1:-host} 17 | FILE_LIST=${2:-""} 18 | 19 | 20 | case $TYPE in 21 | "host") 22 | echo "Running with host stack" 23 | ;; 24 | 25 | "docker") 26 | echo "Running with ddev docker stack" 27 | ;; 28 | 29 | 30 | "ci") 31 | echo "Running in CI context" 32 | ;; 33 | 34 | *) 35 | echo "Unknown param '${TYPE}'" 36 | echo "Usage: ./run-tests.sh " 37 | exit 1 38 | ;; 39 | esac 40 | 41 | 42 | HOSTNAME=$(ddev exec printenv DDEV_HOSTNAME | sed 's/\r//g') 43 | PHP_URL=https://$HOSTNAME 44 | PROXY_IP=$(ddev find-ip ddev-router) 45 | BOUNCER_KEY=$(ddev exec grep "'api_key'" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | tail -1 | sed 's/api_key//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 46 | GEOLOC_ENABLED=$(ddev exec grep -E "'enabled'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/enabled//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 47 | FORCED_TEST_FORWARDED_IP=$(ddev exec grep -E "'forced_test_forwarded_ip'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/forced_test_forwarded_ip//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 48 | CLEAN_CACHE_DURATION=$(ddev exec grep -E "'clean_ip_cache_duration'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/clean_ip_cache_duration//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 49 | STREAM_MODE=$(ddev exec grep -E "'stream_mode'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/stream_mode//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 50 | APPSEC_ENABLED=$(ddev exec grep -E "'use_appsec'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/use_appsec//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 51 | APPSEC_FALLBACK=$(ddev exec grep "'appsec_fallback_remediation'" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | tail -1 | sed 's/appsec_fallback_remediation//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 52 | APPSEC_ACTION=$(ddev exec grep "'appsec_body_size_exceeded_action'" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | tail -1 | sed 's/appsec_body_size_exceeded_action//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 53 | APPSEC_MAX_BODY_SIZE=$(ddev exec grep "'appsec_max_body_size_kb'" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | tail -1 | sed 's/appsec_max_body_size_kb//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 54 | DEBUG_MODE=$(ddev exec grep -E "'debug_mode'.*,$" /var/www/html/my-code/standalone-bouncer/scripts/settings.php | sed 's/debug_mode//g' | sed -e 's|[=>,"'\'']||g' | sed s/'\s'//g) 55 | JEST_PARAMS="--bail=true --runInBand --verbose" 56 | # If FAIL_FAST, will exit on first individual test fail 57 | # @see CustomEnvironment.js 58 | FAIL_FAST=true 59 | 60 | case $TYPE in 61 | "host") 62 | cd "../" 63 | DEBUG_STRING="PWDEBUG=1" 64 | YARN_PATH="./" 65 | COMMAND="yarn --cwd ${YARN_PATH} cross-env" 66 | LAPI_URL_FROM_PLAYWRIGHT=https://localhost:8080 67 | CURRENT_IP=$(ddev find-ip host) 68 | TIMEOUT=31000 69 | HEADLESS=false 70 | SLOWMO=150 71 | AGENT_TLS_PATH="../../../../cfssl" 72 | ;; 73 | 74 | "docker") 75 | DEBUG_STRING="" 76 | YARN_PATH="./my-code/standalone-bouncer/tests/end-to-end" 77 | COMMAND="ddev exec -s playwright yarn --cwd ${YARN_PATH} cross-env" 78 | LAPI_URL_FROM_PLAYWRIGHT=https://crowdsec:8080 79 | CURRENT_IP=$(ddev find-ip playwright) 80 | TIMEOUT=31000 81 | HEADLESS=true 82 | SLOWMO=0 83 | AGENT_TLS_PATH="/var/www/html/cfssl" 84 | ;; 85 | 86 | "ci") 87 | DEBUG_STRING="DEBUG=pw:api" 88 | YARN_PATH="./my-code/standalone-bouncer/tests/end-to-end" 89 | COMMAND="ddev exec -s playwright xvfb-run --auto-servernum -- yarn --cwd ${YARN_PATH} cross-env" 90 | LAPI_URL_FROM_PLAYWRIGHT=https://crowdsec:8080 91 | CURRENT_IP=$(ddev find-ip playwright) 92 | TIMEOUT=60000 93 | HEADLESS=true 94 | SLOWMO=0 95 | AGENT_TLS_PATH="/var/www/html/cfssl" 96 | ;; 97 | 98 | *) 99 | echo "Unknown param '${TYPE}'" 100 | echo "Usage: ./run-tests.sh " 101 | exit 1 102 | ;; 103 | esac 104 | 105 | 106 | 107 | # Run command 108 | 109 | $COMMAND \ 110 | PHP_URL="$PHP_URL" \ 111 | $DEBUG_STRING \ 112 | BOUNCER_KEY="$BOUNCER_KEY" \ 113 | PROXY_IP="$PROXY_IP" \ 114 | GEOLOC_ENABLED="$GEOLOC_ENABLED" \ 115 | APPSEC_ENABLED="$APPSEC_ENABLED" \ 116 | APPSEC_FALLBACK="$APPSEC_FALLBACK" \ 117 | APPSEC_ACTION="$APPSEC_ACTION" \ 118 | APPSEC_MAX_BODY_SIZE="$APPSEC_MAX_BODY_SIZE" \ 119 | STREAM_MODE="$STREAM_MODE" \ 120 | CLEAN_CACHE_DURATION="$CLEAN_CACHE_DURATION" \ 121 | DEBUG_MODE="$DEBUG_MODE" \ 122 | FORCED_TEST_FORWARDED_IP="$FORCED_TEST_FORWARDED_IP" \ 123 | LAPI_URL_FROM_PLAYWRIGHT=$LAPI_URL_FROM_PLAYWRIGHT \ 124 | CURRENT_IP="$CURRENT_IP" \ 125 | TIMEOUT=$TIMEOUT \ 126 | HEADLESS=$HEADLESS \ 127 | FAIL_FAST=$FAIL_FAST \ 128 | SLOWMO=$SLOWMO \ 129 | AGENT_TLS_PATH=$AGENT_TLS_PATH \ 130 | yarn --cwd $YARN_PATH test \ 131 | "$JEST_PARAMS" \ 132 | --json \ 133 | --outputFile=./.test-results.json \ 134 | "$FILE_LIST" 135 | -------------------------------------------------------------------------------- /tests/end-to-end/__scripts__/test-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Prepare Playwright container before testing 3 | # Usage : ./test-init.sh 4 | 5 | YELLOW='\033[33m' 6 | RESET='\033[0m' 7 | if ! ddev --version >/dev/null 2>&1; then 8 | printf "%bDdev is required for this script. Please see docs/ddev.md.%b\n" "${YELLOW}" "${RESET}" 9 | exit 1 10 | fi 11 | 12 | ddev exec -s playwright yarn --cwd ./my-code/standalone-bouncer/tests/end-to-end --force && \ 13 | ddev exec -s playwright yarn global add cross-env 14 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/1-live-mode.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | CURRENT_IP, 4 | CLEAN_CACHE_DURATION, 5 | FORCED_TEST_FORWARDED_IP, 6 | STREAM_MODE, 7 | DEBUG_MODE, 8 | GEOLOC_ENABLED, 9 | DEBUG_LOG_PATH, APPSEC_ENABLED, 10 | } = require("../utils/constants"); 11 | 12 | const { 13 | publicHomepageShouldBeBanWall, 14 | publicHomepageShouldBeCaptchaWallWithMentions, 15 | publicHomepageShouldBeAccessible, 16 | publicHomepageShouldBeCaptchaWall, 17 | banIpForSeconds, 18 | captchaIpForSeconds, 19 | removeAllDecisions, 20 | wait, 21 | runCacheAction, 22 | fillByName, 23 | deleteFileContent, 24 | getFileContent, 25 | } = require("../utils/helpers"); 26 | const { addDecision } = require("../utils/watcherClient"); 27 | 28 | describe(`Live mode run`, () => { 29 | beforeAll(async () => { 30 | await removeAllDecisions(); 31 | await runCacheAction("clear"); 32 | }); 33 | 34 | it("Should have correct settings", async () => { 35 | if (STREAM_MODE) { 36 | const errorMessage = `Stream mode must be disabled for this test`; 37 | console.error(errorMessage); 38 | throw new Error(errorMessage); 39 | } 40 | if (CLEAN_CACHE_DURATION !== "3") { 41 | const errorMessage = `clean_ip_cache_duration setting must be exactly 3 for this test (current is ${CLEAN_CACHE_DURATION})`; 42 | console.error(errorMessage); 43 | throw new Error(errorMessage); 44 | } 45 | if (GEOLOC_ENABLED) { 46 | const errorMessage = "Geolocation MUST be disabled to test this."; 47 | console.error(errorMessage); 48 | throw new Error(errorMessage); 49 | } 50 | if (!DEBUG_MODE) { 51 | const errorMessage = `Debug mode must be enabled for this test`; 52 | console.error(errorMessage); 53 | throw new Error(errorMessage); 54 | } 55 | if (APPSEC_ENABLED) { 56 | const errorMessage = `AppSec must be disabled for this test`; 57 | console.error(errorMessage); 58 | throw new Error(errorMessage); 59 | } 60 | }); 61 | 62 | it("Should display the homepage with no remediation", async () => { 63 | // Empty log file before test 64 | await deleteFileContent(DEBUG_LOG_PATH); 65 | let logContent = await getFileContent(DEBUG_LOG_PATH); 66 | await expect(logContent).toBe(""); 67 | await publicHomepageShouldBeAccessible(); 68 | // count origin: clean/bypass = 1 69 | logContent = await getFileContent(DEBUG_LOG_PATH); 70 | await expect(logContent).toMatch( 71 | new RegExp( 72 | `{"type":"LAPI_REM_CACHED_DECISIONS","ip":"${FORCED_TEST_FORWARDED_IP || CURRENT_IP 73 | }","result":"miss"}`, 74 | ), 75 | ); 76 | await deleteFileContent(DEBUG_LOG_PATH); 77 | logContent = await getFileContent(DEBUG_LOG_PATH); 78 | await expect(logContent).toBe(""); 79 | await publicHomepageShouldBeAccessible(); 80 | // count origin: clean/bypass = 2 81 | logContent = await getFileContent(DEBUG_LOG_PATH); 82 | await expect(logContent).toMatch( 83 | new RegExp( 84 | `{"type":"LAPI_REM_CACHED_DECISIONS","ip":"${FORCED_TEST_FORWARDED_IP || CURRENT_IP 85 | }","result":"hit"}`, 86 | ), 87 | ); 88 | }); 89 | 90 | it("Should display a captcha wall with mentions", async () => { 91 | await captchaIpForSeconds( 92 | 15 * 60, 93 | FORCED_TEST_FORWARDED_IP || CURRENT_IP, 94 | ); 95 | // Wait because clean ip cache duration is 3 seconds 96 | await wait(2000); 97 | await publicHomepageShouldBeCaptchaWallWithMentions(); 98 | // count origin: cscli/captcha = 1,clean/bypass = 2 99 | }); 100 | 101 | it("Should refresh image", async () => { 102 | await runCacheAction( 103 | "captcha-phrase", 104 | `&ip=${FORCED_TEST_FORWARDED_IP || CURRENT_IP}`, 105 | ); 106 | const phrase = await page.$eval("h1", (el) => el.innerText); 107 | await publicHomepageShouldBeCaptchaWall(); 108 | // count origin: cscli/captcha = 2,clean/bypass = 2 109 | await page.click("#refresh_link"); 110 | // count origin: cscli/captcha = 3,clean/bypass = 2 111 | await runCacheAction( 112 | "captcha-phrase", 113 | `&ip=${FORCED_TEST_FORWARDED_IP || CURRENT_IP}`, 114 | ); 115 | const newPhrase = await page.$eval("h1", (el) => el.innerText); 116 | await expect(newPhrase).not.toEqual(phrase); 117 | }); 118 | 119 | it("Should show error message", async () => { 120 | await publicHomepageShouldBeCaptchaWall(); 121 | // count origin: cscli/captcha = 4,clean/bypass = 2 122 | expect(await page.locator(".error").count()).toBeFalsy(); 123 | await fillByName("phrase", "bad-value"); 124 | await page.locator('button:text("CONTINUE")').click(); 125 | expect(await page.locator(".error").count()).toBeTruthy(); 126 | // count origin: cscli/captcha = 5,clean/bypass = 2 127 | await runCacheAction("show-origins-count"); 128 | const originsCount = await page.$eval( 129 | "#origins-count", 130 | (el) => el.innerText, 131 | ); 132 | // Counts depends on previous tests 133 | await expect(originsCount).toEqual( 134 | '{"clean":{"bypass":2},"cscli":{"captcha":5}}', 135 | ); 136 | }); 137 | 138 | it("Should solve the captcha", async () => { 139 | await runCacheAction( 140 | "captcha-phrase", 141 | `&ip=${FORCED_TEST_FORWARDED_IP || CURRENT_IP}`, 142 | ); 143 | const phrase = await page.$eval("h1", (el) => el.innerText); 144 | await publicHomepageShouldBeCaptchaWall(); 145 | // count origin: cscli/captcha = 6,clean/bypass = 2 146 | await fillByName("phrase", phrase); 147 | await page.locator('button:text("CONTINUE")').click(); 148 | // When solving, we are redirect to / that is not accessible (403), so it's not bounced and 149 | // the clean/bypassc ount does not increment 150 | // count origin: cscli/captcha = 6,clean/bypass = 2 151 | await publicHomepageShouldBeAccessible(); 152 | // count origin: cscli/captcha = 6,clean/bypass = 3 153 | await runCacheAction("show-origins-count"); 154 | const originsCount = await page.$eval( 155 | "#origins-count", 156 | (el) => el.innerText, 157 | ); 158 | // Counts depends on previous tests 159 | await expect(originsCount).toEqual( 160 | '{"clean":{"bypass":3},"cscli":{"captcha":6}}', 161 | ); 162 | }); 163 | 164 | it("Should display a ban wall", async () => { 165 | await banIpForSeconds(15 * 60, FORCED_TEST_FORWARDED_IP || CURRENT_IP); 166 | await publicHomepageShouldBeBanWall(); 167 | // count origin: cscli/captcha = 6,clean/bypass = 3,cscli/ban = 1 168 | }); 169 | 170 | it("Should display back the homepage with no remediation", async () => { 171 | await removeAllDecisions(); 172 | await publicHomepageShouldBeAccessible(); 173 | // count origin: cscli/captcha = 6,clean/bypass = 4,cscli/ban = 1 174 | }); 175 | 176 | it("Should push usage metrics", async () => { 177 | // Empty log file before test 178 | await deleteFileContent(DEBUG_LOG_PATH); 179 | let logContent = await getFileContent(DEBUG_LOG_PATH); 180 | await expect(logContent).toBe(""); 181 | await runCacheAction("show-origins-count"); 182 | let originsCount = await page.$eval( 183 | "#origins-count", 184 | (el) => el.innerText, 185 | ); 186 | // Counts depends on previous tests 187 | await expect(originsCount).toEqual( 188 | '{"clean":{"bypass":4},"cscli":{"captcha":6,"ban":1}}', 189 | ); 190 | 191 | await runCacheAction("push-usage-metrics"); 192 | logContent = await getFileContent(DEBUG_LOG_PATH); 193 | await expect(logContent).toMatch( 194 | new RegExp( 195 | `"type":"LAPI_REM_CACHE_METRICS_LAST_SENT"`, 196 | ), 197 | ); 198 | await expect(logContent).toMatch( 199 | new RegExp( 200 | `{"name":"dropped","value":6,"unit":"request","labels":{"origin":"cscli","remediation":"captcha"}},{"name":"dropped","value":1,"unit":"request","labels":{"origin":"cscli","remediation":"ban"}},{"name":"processed","value":11,"unit":"request"}`, 201 | ), 202 | ); 203 | // Test that count has been reset 204 | await runCacheAction("show-origins-count"); 205 | originsCount = await page.$eval( 206 | "#origins-count", 207 | (el) => el.innerText, 208 | ); 209 | await expect(originsCount).toEqual( 210 | '{"clean":{"bypass":0},"cscli":{"captcha":0,"ban":0}}', 211 | ); 212 | await deleteFileContent(DEBUG_LOG_PATH); 213 | logContent = await getFileContent(DEBUG_LOG_PATH); 214 | await expect(logContent).toBe(""); 215 | 216 | }); 217 | 218 | it("Should fallback to the selected remediation for unknown remediation", async () => { 219 | await removeAllDecisions(); 220 | await runCacheAction("clear"); 221 | await addDecision( 222 | FORCED_TEST_FORWARDED_IP || CURRENT_IP, 223 | "mfa", 224 | 15 * 60, 225 | ); 226 | await wait(1000); 227 | await publicHomepageShouldBeCaptchaWall(); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/10-appsec-timeout-bypass.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | removeAllDecisions, 4 | runCacheAction, 5 | publicHomepageShouldBeAccessible, 6 | } = require("../utils/helpers"); 7 | const { APPSEC_ENABLED, APPSEC_FALLBACK } = require("../utils/constants"); 8 | 9 | describe(`Should be bypass by AppSec because of timeout`, () => { 10 | beforeAll(async () => { 11 | await removeAllDecisions(); 12 | await runCacheAction("clear"); 13 | }); 14 | 15 | it("Should have correct settings", async () => { 16 | if (!APPSEC_ENABLED) { 17 | const errorMessage = `AppSec must be enabled for this test`; 18 | console.error(errorMessage); 19 | throw new Error(errorMessage); 20 | } 21 | if ( 22 | !["bypass", "Constants::REMEDIATION_BYPASS"].includes( 23 | APPSEC_FALLBACK, 24 | ) 25 | ) { 26 | const errorMessage = `AppSec fallback must be "bypass" for this test (got ${APPSEC_FALLBACK})`; 27 | console.error(errorMessage); 28 | throw new Error(errorMessage); 29 | } 30 | }); 31 | 32 | it("Should bypass for home page as this is the appsec fallback remediation", async () => { 33 | await publicHomepageShouldBeAccessible(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/11-appsec-max-body-ban.js: -------------------------------------------------------------------------------- 1 | const { 2 | goToPublicPage, 3 | removeAllDecisions, 4 | runCacheAction, 5 | computeCurrentPageRemediation, 6 | fillInput, 7 | clickById, 8 | getTextById, 9 | wait, 10 | } = require("../utils/helpers"); 11 | const { 12 | APPSEC_ENABLED, 13 | APPSEC_MAX_BODY_SIZE, 14 | APPSEC_ACTION, 15 | STREAM_MODE, 16 | CLEAN_CACHE_DURATION, 17 | } = require("../utils/constants"); 18 | 19 | describe(`Should work with ban as max body`, () => { 20 | beforeAll(async () => { 21 | await removeAllDecisions(); 22 | await runCacheAction("clear"); 23 | }); 24 | 25 | it("Should have correct settings", async () => { 26 | if (!APPSEC_ENABLED) { 27 | const errorMessage = `AppSec must be enabled for this test`; 28 | console.error(errorMessage); 29 | throw new Error(errorMessage); 30 | } 31 | if (STREAM_MODE) { 32 | const errorMessage = `Stream mode must be disabled for this test`; 33 | console.error(errorMessage); 34 | throw new Error(errorMessage); 35 | } 36 | if (CLEAN_CACHE_DURATION !== "3") { 37 | const errorMessage = `clean_ip_cache_duration setting must be exactly 3 for this test (current is ${CLEAN_CACHE_DURATION})`; 38 | console.error(errorMessage); 39 | throw new Error(errorMessage); 40 | } 41 | if (APPSEC_ACTION !== "block") { 42 | const errorMessage = `AppSec action must be "block" for this test (got ${APPSEC_ACTION})`; 43 | console.error(errorMessage); 44 | throw new Error(errorMessage); 45 | } 46 | }); 47 | 48 | it("Should ban when access home page page with POST and too big body", async () => { 49 | await goToPublicPage(); 50 | const remediation = await computeCurrentPageRemediation(); 51 | await expect(remediation).toBe("bypass"); 52 | 53 | let appsecResult = await getTextById("appsec-result"); 54 | await expect(appsecResult).toBe("INITIAL STATE"); 55 | 56 | await fillInput( 57 | "request-body", 58 | "a".repeat(APPSEC_MAX_BODY_SIZE * 1024 + 1), 59 | ); 60 | await clickById("appsec-post-button"); 61 | await wait(1000); 62 | 63 | appsecResult = await getTextById("appsec-result"); 64 | await expect(appsecResult).toBe("Response status: 403"); 65 | }); 66 | 67 | it("Should bypass when access home page page with POST and short body", async () => { 68 | await goToPublicPage(); 69 | const remediation = await computeCurrentPageRemediation(); 70 | await expect(remediation).toBe("bypass"); 71 | 72 | let appsecResult = await getTextById("appsec-result"); 73 | await expect(appsecResult).toBe("INITIAL STATE"); 74 | 75 | await fillInput( 76 | "request-body", 77 | "a".repeat(APPSEC_MAX_BODY_SIZE * 1024), 78 | ); 79 | await clickById("appsec-post-button"); 80 | await wait(1000); 81 | 82 | appsecResult = await getTextById("appsec-result"); 83 | await expect(appsecResult).toBe("Response status: 200"); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/12-appsec-upload.js: -------------------------------------------------------------------------------- 1 | const { 2 | goToPublicPage, 3 | removeAllDecisions, 4 | runCacheAction, 5 | computeCurrentPageRemediation, 6 | getHtmlById, 7 | clickById, 8 | getTextById, 9 | wait, 10 | } = require("../utils/helpers"); 11 | const { 12 | APPSEC_ENABLED, 13 | APPSEC_MAX_BODY_SIZE, 14 | APPSEC_ACTION, 15 | STREAM_MODE, 16 | CLEAN_CACHE_DURATION, 17 | APPSEC_UPLOAD_TEST_URL, 18 | } = require("../utils/constants"); 19 | 20 | describe(`Should work with ban as max body`, () => { 21 | beforeAll(async () => { 22 | await removeAllDecisions(); 23 | await runCacheAction("clear"); 24 | }); 25 | 26 | it("Should have correct settings", async () => { 27 | if (!APPSEC_ENABLED) { 28 | const errorMessage = `AppSec must be enabled for this test`; 29 | console.error(errorMessage); 30 | throw new Error(errorMessage); 31 | } 32 | if (STREAM_MODE) { 33 | const errorMessage = `Stream mode must be disabled for this test`; 34 | console.error(errorMessage); 35 | throw new Error(errorMessage); 36 | } 37 | if (CLEAN_CACHE_DURATION !== "3") { 38 | const errorMessage = `clean_ip_cache_duration setting must be exactly 3 for this test (current is ${CLEAN_CACHE_DURATION})`; 39 | console.error(errorMessage); 40 | throw new Error(errorMessage); 41 | } 42 | if (APPSEC_ACTION !== "block") { 43 | const errorMessage = `AppSec action must be "block" for this test (got ${APPSEC_ACTION})`; 44 | console.error(errorMessage); 45 | throw new Error(errorMessage); 46 | } 47 | if (APPSEC_MAX_BODY_SIZE > 1024) { 48 | const errorMessage = `AppSec max size must less than "1024" for this test (got ${APPSEC_MAX_BODY_SIZE})`; 49 | console.error(errorMessage); 50 | throw new Error(errorMessage); 51 | } 52 | }); 53 | 54 | it("Should ban when upload a too big image", async () => { 55 | await goToPublicPage(APPSEC_UPLOAD_TEST_URL); 56 | const remediation = await computeCurrentPageRemediation("Image Upload"); 57 | await expect(remediation).toBe("bypass"); 58 | 59 | let appsecResult = await getTextById("appsec-result"); 60 | await expect(appsecResult).toBe("INITIAL STATE"); 61 | 62 | await page 63 | .locator('input[name="image"]') 64 | .setInputFiles("./assets/too-big.jpg"); 65 | await clickById("imageUpload"); 66 | await wait(2000); 67 | 68 | appsecResult = await getTextById("appsec-result"); 69 | await expect(appsecResult).toBe("403"); 70 | }); 71 | 72 | it("Should bypass when upload a small enough image", async () => { 73 | await goToPublicPage(APPSEC_UPLOAD_TEST_URL); 74 | const remediation = await computeCurrentPageRemediation("Image Upload"); 75 | await expect(remediation).toBe("bypass"); 76 | 77 | let appsecResult = await getTextById("appsec-result"); 78 | await expect(appsecResult).toBe("INITIAL STATE"); 79 | 80 | await page 81 | .locator('input[name="image"]') 82 | .setInputFiles("./assets/small-enough.jpg"); 83 | await clickById("imageUpload"); 84 | await wait(2000); 85 | 86 | appsecResult = await getHtmlById("appsec-result"); 87 | await expect(appsecResult).toBe( 88 | 'Uploaded Image', 89 | ); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/2-live-mode-with-geolocation.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | GEOLOC_ENABLED, 4 | FORCED_TEST_FORWARDED_IP, 5 | CLEAN_CACHE_DURATION, 6 | GEOLOC_BAD_COUNTRY, 7 | STREAM_MODE, 8 | JAPAN_IP, 9 | } = require("../utils/constants"); 10 | 11 | const { 12 | publicHomepageShouldBeBanWall, 13 | publicHomepageShouldBeAccessible, 14 | banIpForSeconds, 15 | removeAllDecisions, 16 | wait, 17 | } = require("../utils/helpers"); 18 | const { addDecision } = require("../utils/watcherClient"); 19 | 20 | describe(`Live mode run with geolocation`, () => { 21 | beforeAll(async () => { 22 | await removeAllDecisions(); 23 | }); 24 | 25 | it("Should have correct settings", async () => { 26 | if (STREAM_MODE) { 27 | const errorMessage = `Stream mode must be disabled for this test`; 28 | console.error(errorMessage); 29 | throw new Error(errorMessage); 30 | } 31 | if (CLEAN_CACHE_DURATION !== "1") { 32 | const errorMessage = `clean_ip_cache_duration setting must be exactly 1 for this test`; 33 | console.error(errorMessage); 34 | throw new Error(errorMessage); 35 | } 36 | if (!GEOLOC_ENABLED) { 37 | const errorMessage = "Geolocation MUST be enabled to test this."; 38 | console.error(errorMessage); 39 | throw new Error(errorMessage); 40 | } 41 | // Test with a Japan IP 42 | if (FORCED_TEST_FORWARDED_IP !== JAPAN_IP) { 43 | const errorMessage = `A forced test forwarded ip MUST be set and equals to '${JAPAN_IP}'."forced_test_forwarded_ip" setting was: ${FORCED_TEST_FORWARDED_IP}`; 44 | console.error(errorMessage); 45 | throw new Error(errorMessage); 46 | } 47 | }); 48 | 49 | it("Should bypass a clean IP with a clean country", async () => { 50 | await publicHomepageShouldBeAccessible(); 51 | }); 52 | 53 | it("Should ban a bad IP (ban) with a clean country", async () => { 54 | await banIpForSeconds(15 * 60, FORCED_TEST_FORWARDED_IP); 55 | await publicHomepageShouldBeBanWall(); 56 | }); 57 | 58 | it("Should ban a clean IP with a bad country (ban)", async () => { 59 | await removeAllDecisions(); 60 | await addDecision(GEOLOC_BAD_COUNTRY, "ban", 15 * 60, "Country"); 61 | await wait(1000); 62 | await publicHomepageShouldBeBanWall(); 63 | }); 64 | 65 | it("Should ban a bad IP (ban) with a bad country (captcha)", async () => { 66 | await removeAllDecisions(); 67 | await addDecision(GEOLOC_BAD_COUNTRY, "captcha", 15 * 60, "Country"); 68 | await addDecision(FORCED_TEST_FORWARDED_IP, "ban", 15 * 60); 69 | await wait(1000); 70 | await publicHomepageShouldBeBanWall(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/3-stream-mode.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | CURRENT_IP, 4 | FORCED_TEST_FORWARDED_IP, 5 | STREAM_MODE, 6 | GEOLOC_ENABLED, 7 | CLEAN_CACHE_DURATION, 8 | APPSEC_ENABLED, 9 | DEBUG_LOG_PATH, 10 | } = require("../utils/constants"); 11 | 12 | const { 13 | publicHomepageShouldBeAccessible, 14 | publicHomepageShouldBeCaptchaWall, 15 | captchaIpForSeconds, 16 | removeAllDecisions, 17 | runCacheAction, 18 | deleteFileContent, 19 | getFileContent, 20 | } = require("../utils/helpers"); 21 | 22 | describe(`Stream mode run`, () => { 23 | beforeAll(async () => { 24 | await removeAllDecisions(); 25 | }); 26 | 27 | it("Should have correct settings", async () => { 28 | if (!STREAM_MODE) { 29 | const errorMessage = `Stream mode must be enabled for this test`; 30 | console.error(errorMessage); 31 | throw new Error(errorMessage); 32 | } 33 | if (GEOLOC_ENABLED) { 34 | const errorMessage = "Geolocation MUST be disabled to test this."; 35 | console.error(errorMessage); 36 | throw new Error(errorMessage); 37 | } 38 | if (CLEAN_CACHE_DURATION !== "1") { 39 | const errorMessage = `clean_ip_cache_duration setting must be exactly 1 for this test`; 40 | console.error(errorMessage); 41 | throw new Error(errorMessage); 42 | } 43 | if (FORCED_TEST_FORWARDED_IP !== null) { 44 | const errorMessage = `A forced test forwarded ip MUST NOT be set."forced_test_forwarded_ip" setting was: ${FORCED_TEST_FORWARDED_IP}`; 45 | console.error(errorMessage); 46 | throw new Error(errorMessage); 47 | } 48 | if (APPSEC_ENABLED) { 49 | const errorMessage = `AppSec must be disabled for this test`; 50 | console.error(errorMessage); 51 | throw new Error(errorMessage); 52 | } 53 | }); 54 | 55 | it("Should display the homepage with no remediation", async () => { 56 | await runCacheAction("clear"); 57 | await publicHomepageShouldBeAccessible(); 58 | // count origin: clean/bypass = 1 59 | }); 60 | 61 | it("Should still bypass as cache has not been refreshed", async () => { 62 | await captchaIpForSeconds(15 * 60, CURRENT_IP); 63 | await publicHomepageShouldBeAccessible(); 64 | // count origin: clean/bypass = 2 65 | await runCacheAction("show-origins-count"); 66 | const originsCount = await page.$eval( 67 | "#origins-count", 68 | (el) => el.innerText, 69 | ); 70 | // Counts depends on previous tests 71 | await expect(originsCount).toEqual( 72 | '{"clean":{"bypass":2}}', 73 | ); 74 | }); 75 | 76 | it("Should display a captcha wall after cache refresh", async () => { 77 | await runCacheAction("refresh"); 78 | await publicHomepageShouldBeCaptchaWall(); 79 | // The first refresh clear the cache (during the warmup), so we loose the first metrics 80 | // count origin: cscli/captcha = 1 81 | await runCacheAction("show-origins-count"); 82 | const originsCount = await page.$eval( 83 | "#origins-count", 84 | (el) => el.innerText, 85 | ); 86 | // Counts depends on previous tests 87 | await expect(originsCount).toEqual( 88 | '{"cscli":{"captcha":1}}', 89 | ); 90 | }); 91 | 92 | it("Should still display a captcha wall as cache has not been refreshed", async () => { 93 | await removeAllDecisions(); 94 | await publicHomepageShouldBeCaptchaWall(); 95 | // count origin: cscli/captcha = 2 96 | await runCacheAction("show-origins-count"); 97 | const originsCount = await page.$eval( 98 | "#origins-count", 99 | (el) => el.innerText, 100 | ); 101 | // Counts depends on previous tests 102 | await expect(originsCount).toEqual( 103 | '{"cscli":{"captcha":2}}', 104 | ); 105 | }); 106 | 107 | it("Should bypass after cache refresh", async () => { 108 | await runCacheAction("refresh"); 109 | await publicHomepageShouldBeAccessible(); 110 | // count origin: cscli/captcha = 2,clean/bypass = 3 111 | await runCacheAction("show-origins-count"); 112 | const originsCount = await page.$eval( 113 | "#origins-count", 114 | (el) => el.innerText, 115 | ); 116 | // Counts depends on previous tests 117 | await expect(originsCount).toEqual( 118 | '{"cscli":{"captcha":2},"clean":{"bypass":1}}', 119 | ); 120 | }); 121 | 122 | it("Should push usage metrics", async () => { 123 | // Empty log file before test 124 | await deleteFileContent(DEBUG_LOG_PATH); 125 | let logContent = await getFileContent(DEBUG_LOG_PATH); 126 | await expect(logContent).toBe(""); 127 | await runCacheAction("show-origins-count"); 128 | let originsCount = await page.$eval( 129 | "#origins-count", 130 | (el) => el.innerText, 131 | ); 132 | // Counts depends on previous tests 133 | await expect(originsCount).toEqual( 134 | '{"cscli":{"captcha":2},"clean":{"bypass":1}}', 135 | ); 136 | 137 | await runCacheAction("push-usage-metrics"); 138 | logContent = await getFileContent(DEBUG_LOG_PATH); 139 | await expect(logContent).toMatch( 140 | new RegExp( 141 | `"type":"LAPI_REM_CACHE_METRICS_LAST_SENT"`, 142 | ), 143 | ); 144 | await expect(logContent).toMatch( 145 | new RegExp( 146 | `{"name":"dropped","value":2,"unit":"request","labels":{"origin":"cscli","remediation":"captcha"}},{"name":"processed","value":3,"unit":"request"}`, 147 | ), 148 | ); 149 | // Test that count has been reset 150 | await runCacheAction("show-origins-count"); 151 | originsCount = await page.$eval( 152 | "#origins-count", 153 | (el) => el.innerText, 154 | ); 155 | await expect(originsCount).toEqual( 156 | '{"cscli":{"captcha":0},"clean":{"bypass":0}}', 157 | ); 158 | await deleteFileContent(DEBUG_LOG_PATH); 159 | logContent = await getFileContent(DEBUG_LOG_PATH); 160 | await expect(logContent).toBe(""); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/4-geolocation.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | JAPAN_IP, 4 | FRANCE_IP, 5 | CLEAN_CACHE_DURATION, 6 | } = require("../utils/constants"); 7 | 8 | const { removeAllDecisions, runGeolocationTest } = require("../utils/helpers"); 9 | 10 | describe(`Geolocation standalone run`, () => { 11 | beforeAll(async () => { 12 | await removeAllDecisions(); 13 | }); 14 | 15 | it("Should have correct settings", async () => { 16 | if (CLEAN_CACHE_DURATION !== "1") { 17 | const errorMessage = `clean_ip_cache_duration setting must be exactly 1 for this test`; 18 | console.error(errorMessage); 19 | throw new Error(errorMessage); 20 | } 21 | }); 22 | 23 | it("Should get JP", async () => { 24 | await runGeolocationTest(JAPAN_IP, false); 25 | await expect(page).toMatchText(/Country: JP/); 26 | }); 27 | 28 | it("Should get FR", async () => { 29 | await runGeolocationTest(FRANCE_IP, false); 30 | await expect(page).toMatchText(/Country: FR/); 31 | }); 32 | 33 | it("Should call the database as we did not save result", async () => { 34 | await runGeolocationTest(FRANCE_IP, false, true); 35 | await expect(page).toMatchText(/Error message: The file/); 36 | }); 37 | 38 | it("Should not call the GeoIp database as result is saved in cache", async () => { 39 | await runGeolocationTest(FRANCE_IP, true); 40 | await expect(page).toMatchText(/Country: FR/); 41 | await runGeolocationTest(FRANCE_IP, true, true); 42 | await expect(page).toMatchText(/Country: FR/); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/5-display-error-off.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { publicHomepageShouldBeAccessible } = require("../utils/helpers"); 3 | const { CLEAN_CACHE_DURATION } = require("../utils/constants"); 4 | 5 | describe(`Should not display errors`, () => { 6 | it("Should have correct settings", async () => { 7 | if (CLEAN_CACHE_DURATION !== "1") { 8 | const errorMessage = `clean_ip_cache_duration setting must be exactly 1 for this test`; 9 | console.error(errorMessage); 10 | throw new Error(errorMessage); 11 | } 12 | }); 13 | 14 | it("Should not display error", async () => { 15 | await publicHomepageShouldBeAccessible(); 16 | await expect(page).not.toHaveText("body", "Fatal error"); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/6-display-error-on.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { goToPublicPage } = require("../utils/helpers"); 3 | const { CLEAN_CACHE_DURATION } = require("../utils/constants"); 4 | 5 | describe(`Should display errors`, () => { 6 | it("Should have correct settings", async () => { 7 | if (CLEAN_CACHE_DURATION !== "1") { 8 | const errorMessage = `clean_ip_cache_duration setting must be exactly 1 for this test`; 9 | console.error(errorMessage); 10 | throw new Error(errorMessage); 11 | } 12 | }); 13 | it("Should display error (if settings ko or something wrong while bouncing)", async () => { 14 | await goToPublicPage(); 15 | await expect(page).toHaveText("body", "Fatal error"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/7-appsec.js: -------------------------------------------------------------------------------- 1 | const { 2 | goToPublicPage, 3 | removeAllDecisions, 4 | runCacheAction, 5 | computeCurrentPageRemediation, 6 | publicHomepageShouldBeAccessible, 7 | fillInput, 8 | clickById, 9 | getTextById, 10 | captchaIpForSeconds, 11 | wait, 12 | deleteFileContent, 13 | getFileContent, 14 | } = require("../utils/helpers"); 15 | const { 16 | APPSEC_ENABLED, 17 | APPSEC_TEST_URL, 18 | APPSEC_MALICIOUS_BODY, 19 | STREAM_MODE, 20 | FORCED_TEST_FORWARDED_IP, 21 | CURRENT_IP, 22 | CLEAN_CACHE_DURATION, 23 | DEBUG_LOG_PATH, 24 | } = require("../utils/constants"); 25 | 26 | describe(`Should be ban by AppSec`, () => { 27 | beforeAll(async () => { 28 | await removeAllDecisions(); 29 | await runCacheAction("clear"); 30 | }); 31 | 32 | it("Should have correct settings", async () => { 33 | if (!APPSEC_ENABLED) { 34 | const errorMessage = `AppSec must be enabled for this test`; 35 | console.error(errorMessage); 36 | throw new Error(errorMessage); 37 | } 38 | if (STREAM_MODE) { 39 | const errorMessage = `Stream mode must be disabled for this test`; 40 | console.error(errorMessage); 41 | throw new Error(errorMessage); 42 | } 43 | if (CLEAN_CACHE_DURATION !== "3") { 44 | const errorMessage = `clean_ip_cache_duration setting must be exactly 3 for this test (current is ${CLEAN_CACHE_DURATION})`; 45 | console.error(errorMessage); 46 | throw new Error(errorMessage); 47 | } 48 | }); 49 | 50 | it("Should bypass for home page GET", async () => { 51 | await publicHomepageShouldBeAccessible(); 52 | // count origin: clean_appsec/bypass = 1 53 | await runCacheAction("show-origins-count"); 54 | const originsCount = await page.$eval( 55 | "#origins-count", 56 | (el) => el.innerText, 57 | ); 58 | await expect(originsCount).toEqual( 59 | '{"clean_appsec":{"bypass":1}}', 60 | ); 61 | }); 62 | 63 | it("Should ban when access AppSec test page with GET", async () => { 64 | await goToPublicPage(APPSEC_TEST_URL); 65 | const remediation = await computeCurrentPageRemediation("Test AppSec"); 66 | await expect(remediation).toBe("ban"); 67 | // count origin: clean_appsec/bypass = 1, appsec/ban = 1 68 | }); 69 | 70 | it("Should ban when access home page page with POST and malicious body", async () => { 71 | await goToPublicPage(); 72 | const remediation = await computeCurrentPageRemediation(); 73 | await expect(remediation).toBe("bypass"); 74 | // count origin: clean_appsec/bypass = 2, appsec/ban = 1 75 | 76 | let appsecResult = await getTextById("appsec-result"); 77 | await expect(appsecResult).toBe("INITIAL STATE"); 78 | 79 | await fillInput("request-body", APPSEC_MALICIOUS_BODY); 80 | await clickById("appsec-post-button"); 81 | await wait(1000); 82 | 83 | appsecResult = await getTextById("appsec-result"); 84 | await expect(appsecResult).toBe("Response status: 403"); 85 | // count origin: clean_appsec/bypass = 2, appsec/ban = 2 86 | }); 87 | 88 | it("Should bypass when access home page page with POST and clean body", async () => { 89 | await goToPublicPage(); 90 | const remediation = await computeCurrentPageRemediation(); 91 | await expect(remediation).toBe("bypass"); 92 | // count origin: clean_appsec/bypass = 3, appsec/ban = 2 93 | 94 | let appsecResult = await getTextById("appsec-result"); 95 | await expect(appsecResult).toBe("INITIAL STATE"); 96 | 97 | await fillInput("request-body", "OK"); 98 | await clickById("appsec-post-button"); 99 | await wait(1000); 100 | 101 | appsecResult = await getTextById("appsec-result"); 102 | await expect(appsecResult).toBe("Response status: 200"); 103 | // count origin: clean_appsec/bypass = 4, appsec/ban = 2 104 | }); 105 | 106 | it("Should not use AppSec if LAPI remediation is not a bypass", async () => { 107 | await goToPublicPage(APPSEC_TEST_URL); 108 | let remediation = await computeCurrentPageRemediation("Test AppSec"); 109 | await expect(remediation).toBe("ban"); 110 | // count origin: clean_appsec/bypass = 4, appsec/ban = 3 111 | 112 | await captchaIpForSeconds( 113 | 15 * 60, 114 | FORCED_TEST_FORWARDED_IP || CURRENT_IP, 115 | ); 116 | // Wait because clean ip cache duration is 3 seconds 117 | await wait(2000); 118 | await goToPublicPage(APPSEC_TEST_URL); 119 | remediation = await computeCurrentPageRemediation("Test AppSec"); 120 | await expect(remediation).toBe("captcha"); 121 | // count origin: clean_appsec/bypass = 4, appsec/ban = 3, cscli/captcha = 1 122 | }); 123 | 124 | it("Should push usage metrics", async () => { 125 | // Empty log file before test 126 | await deleteFileContent(DEBUG_LOG_PATH); 127 | let logContent = await getFileContent(DEBUG_LOG_PATH); 128 | await expect(logContent).toBe(""); 129 | await runCacheAction("show-origins-count"); 130 | let originsCount = await page.$eval( 131 | "#origins-count", 132 | (el) => el.innerText, 133 | ); 134 | // Counts depends on previous tests 135 | await expect(originsCount).toEqual( 136 | '{"clean_appsec":{"bypass":4},"appsec":{"ban":3},"cscli":{"captcha":1}}', 137 | ); 138 | 139 | await runCacheAction("push-usage-metrics"); 140 | logContent = await getFileContent(DEBUG_LOG_PATH); 141 | await expect(logContent).toMatch( 142 | new RegExp( 143 | `"type":"LAPI_REM_CACHE_METRICS_LAST_SENT"`, 144 | ), 145 | ); 146 | await expect(logContent).toMatch( 147 | new RegExp( 148 | `{"name":"dropped","value":3,"unit":"request","labels":{"origin":"appsec","remediation":"ban"}},{"name":"dropped","value":1,"unit":"request","labels":{"origin":"cscli","remediation":"captcha"}},{"name":"processed","value":8,"unit":"request"}`, 149 | ), 150 | ); 151 | // Test that count has been reset 152 | await runCacheAction("show-origins-count"); 153 | originsCount = await page.$eval( 154 | "#origins-count", 155 | (el) => el.innerText, 156 | ); 157 | await expect(originsCount).toEqual( 158 | '{"clean_appsec":{"bypass":0},"appsec":{"ban":0},"cscli":{"captcha":0}}', 159 | ); 160 | await deleteFileContent(DEBUG_LOG_PATH); 161 | logContent = await getFileContent(DEBUG_LOG_PATH); 162 | await expect(logContent).toBe(""); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/8-appsec-timeout-captcha.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | removeAllDecisions, 4 | runCacheAction, 5 | publicHomepageShouldBeCaptchaWall, 6 | fillByName, 7 | publicHomepageShouldBeAccessible, 8 | } = require("../utils/helpers"); 9 | const { 10 | APPSEC_ENABLED, 11 | APPSEC_FALLBACK, 12 | FORCED_TEST_FORWARDED_IP, 13 | CURRENT_IP, 14 | } = require("../utils/constants"); 15 | 16 | describe(`Should be captcha by AppSec because of timeout`, () => { 17 | beforeAll(async () => { 18 | await removeAllDecisions(); 19 | await runCacheAction("clear"); 20 | }); 21 | 22 | it("Should have correct settings", async () => { 23 | if (!APPSEC_ENABLED) { 24 | const errorMessage = `AppSec must be enabled for this test`; 25 | console.error(errorMessage); 26 | throw new Error(errorMessage); 27 | } 28 | if ( 29 | !["captcha", "Constants::REMEDIATION_CAPTCHA"].includes( 30 | APPSEC_FALLBACK, 31 | ) 32 | ) { 33 | const errorMessage = `AppSec fallback must be "captcha" for this test (got ${APPSEC_FALLBACK})`; 34 | console.error(errorMessage); 35 | throw new Error(errorMessage); 36 | } 37 | }); 38 | 39 | it("Should captcha for home page as this is the appsec fallback remediation", async () => { 40 | await publicHomepageShouldBeCaptchaWall(); 41 | }); 42 | 43 | it("Should solve the captcha", async () => { 44 | await runCacheAction( 45 | "captcha-phrase", 46 | `&ip=${FORCED_TEST_FORWARDED_IP || CURRENT_IP}`, 47 | ); 48 | const phrase = await page.$eval("h1", (el) => el.innerText); 49 | await publicHomepageShouldBeCaptchaWall(); 50 | await fillByName("phrase", phrase); 51 | await page.locator('button:text("CONTINUE")').click(); 52 | await publicHomepageShouldBeAccessible(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/end-to-end/__tests__/9-appsec-timeout-ban.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { 3 | removeAllDecisions, 4 | runCacheAction, 5 | publicHomepageShouldBeBanWall, 6 | } = require("../utils/helpers"); 7 | const { APPSEC_ENABLED, APPSEC_FALLBACK } = require("../utils/constants"); 8 | 9 | describe(`Should be ban by AppSec because of timeout`, () => { 10 | beforeAll(async () => { 11 | await removeAllDecisions(); 12 | await runCacheAction("clear"); 13 | }); 14 | 15 | it("Should have correct settings", async () => { 16 | if (!APPSEC_ENABLED) { 17 | const errorMessage = `AppSec must be enabled for this test`; 18 | console.error(errorMessage); 19 | throw new Error(errorMessage); 20 | } 21 | if (!["ban", "Constants::REMEDIATION_BAN"].includes(APPSEC_FALLBACK)) { 22 | const errorMessage = `AppSec fallback must be "ban" for this test (got ${APPSEC_FALLBACK})`; 23 | console.error(errorMessage); 24 | throw new Error(errorMessage); 25 | } 26 | }); 27 | 28 | it("Should ban for home page as this is the appsec fallback remediation", async () => { 29 | await publicHomepageShouldBeBanWall(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/end-to-end/assets/small-enough.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/tests/end-to-end/assets/small-enough.jpg -------------------------------------------------------------------------------- /tests/end-to-end/assets/too-big.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/tests/end-to-end/assets/too-big.jpg -------------------------------------------------------------------------------- /tests/end-to-end/jest-playwright.config.js: -------------------------------------------------------------------------------- 1 | const headless = process.env.HEADLESS; 2 | const slowMo = parseFloat(process.env.SLOWMO); 3 | module.exports = { 4 | launchOptions: { 5 | headless, 6 | }, 7 | connectOptions: { slowMo }, 8 | exitOnPageError: false, 9 | contextOptions: { 10 | ignoreHTTPSErrors: true, 11 | viewport: { 12 | width: 1920, 13 | height: 1080, 14 | }, 15 | }, 16 | browsers: ["chromium"], 17 | devices: ["Desktop Chrome"], 18 | }; 19 | -------------------------------------------------------------------------------- /tests/end-to-end/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-playwright-preset", 3 | testRunner: "jest-circus/runner", 4 | testEnvironment: "./CustomEnvironment.js", 5 | testSequencer: "./testSequencer.js", 6 | setupFilesAfterEnv: ["expect-playwright"], 7 | }; 8 | -------------------------------------------------------------------------------- /tests/end-to-end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "scripts": { 4 | "test": "jest" 5 | }, 6 | "devDependencies": { 7 | "eslint": "^7.32.0", 8 | "eslint-config-airbnb-base": "^14.2.1", 9 | "eslint-config-prettier": "^8.3.0", 10 | "eslint-plugin-import": "^2.24.1", 11 | "eslint-plugin-prettier": "^3.4.0", 12 | "prettier": "^2.3.2" 13 | }, 14 | "dependencies": { 15 | "@jest/test-sequencer": "^26.6.3", 16 | "axios": "^0.21.1", 17 | "cross-env": "^7.0.3", 18 | "expect-playwright": "^0.8.0", 19 | "hosted-git-info": "^2.8.9", 20 | "jest": "^26.6.3", 21 | "jest-circus": "^26.6.3", 22 | "jest-environment-node": "^27.2.0", 23 | "jest-playwright-preset": "^1.4.3", 24 | "jest-runner": "^26.6.3", 25 | "lodash": "^4.17.21", 26 | "playwright-chromium": "^1.37.1", 27 | "ws": "^7.4.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/end-to-end/settings/base.php.dist: -------------------------------------------------------------------------------- 1 | 'api_key', 8 | 'api_url' => 'https://crowdsec:8080', 9 | 'appsec_url' => 'http://crowdsec:7422', 10 | 'api_key' => 'REPLACE_API_KEY', 11 | 'api_timeout' => 10, 12 | 'api_connect_timeout' => 5, 13 | 'appsec_timeout_ms' => Constants::APPSEC_TIMEOUT_MS, 14 | 'appsec_connect_timeout' => Constants::APPSEC_CONNECT_TIMEOUT_MS, 15 | 'use_curl' => false, 16 | 'tls_cert_path' => '', 17 | 'tls_key_path' => '', 18 | 'tls_verify_peer' => true, 19 | 'tls_ca_cert_path' => '', 20 | // Debug/Test 21 | 'debug_mode' => true, 22 | 'display_errors' => true, 23 | 'log_directory_path' => __DIR__ . '/.logs', 24 | 'fs_cache_path' => __DIR__ . '/.cache', 25 | 'forced_test_ip' => 'REPLACE_FORCED_IP', 26 | 'forced_test_forwarded_ip' => 'REPLACE_FORCED_FORWARDED_IP', 27 | // Bouncer 28 | 'bouncing_level' => Constants::BOUNCING_LEVEL_NORMAL, 29 | 'stream_mode' => false, 30 | 'excluded_uris' => ['/favicon.ico'], 31 | 'fallback_remediation' => Constants::REMEDIATION_CAPTCHA, 32 | 'use_appsec' => false, 33 | 'appsec_max_body_size_kb' => 1024, 34 | 'appsec_body_size_exceeded_action' => 'headers_only', 35 | 'appsec_fallback_remediation' => Constants::REMEDIATION_CAPTCHA, 36 | 'trust_ip_forward_array' => ['REPLACE_PROXY_IP'], 37 | // Cache 38 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 39 | 'redis_dsn' => 'redis://redis:6379', 40 | 'memcached_dsn' => 'memcached://memcached:11211', 41 | 'clean_ip_cache_duration' => 1, 42 | 'bad_ip_cache_duration' => 1, 43 | 'captcha_cache_duration' => 86400, 44 | // Geolocation 45 | 'geolocation' => [ 46 | 'cache_duration' => 86400, 47 | 'enabled' => false, 48 | 'type' => 'maxmind', 49 | 'maxmind' => [ 50 | 'database_type' => 'country', 51 | 'database_path' => '/var/www/html/my-code/standalone-bouncer/tests/GeoLite2-Country.mmdb' 52 | ] 53 | ], 54 | // Settings for ban and captcha walls 55 | 'custom_css' => '', 56 | // true to hide CrowdSec mentions on ban and captcha walls. 57 | 'hide_mentions' => false, 58 | 'color' => [ 59 | 'text' => [ 60 | 'primary' => 'black', 61 | 'secondary' => '#AAA', 62 | 'button' => 'white', 63 | 'error_message' => '#b90000', 64 | ], 65 | 'background' => [ 66 | 'page' => '#eee', 67 | 'container' => 'white', 68 | 'button' => '#626365', 69 | 'button_hover' => '#333', 70 | ], 71 | ], 72 | 'text' => [ 73 | // Settings for captcha wall 74 | 'captcha_wall' => [ 75 | 'tab_title' => 'Oops..', 76 | 'title' => 'Hmm, sorry but...', 77 | 'subtitle' => 'Please complete the security check.', 78 | 'refresh_image_link' => 'refresh image', 79 | 'captcha_placeholder' => 'Type here...', 80 | 'send_button' => 'CONTINUE', 81 | 'error_message' => 'Please try again.', 82 | 'footer' => '', 83 | ], 84 | // Settings for ban wall 85 | 'ban_wall' => [ 86 | 'tab_title' => 'Oops..', 87 | 'title' => '🤭 Oh!', 88 | 'subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.', 89 | 'footer' => '', 90 | ], 91 | ], 92 | ]; 93 | -------------------------------------------------------------------------------- /tests/end-to-end/testSequencer.js: -------------------------------------------------------------------------------- 1 | const Sequencer = require("@jest/test-sequencer").default; 2 | 3 | class CustomSequencer extends Sequencer { 4 | sort(tests) { 5 | // Test structure information 6 | // https://github.com/facebook/jest/blob/6b8b1404a1d9254e7d5d90a8934087a9c9899dab/packages/jest-runner/src/types.ts#L17-L21 7 | const copyTests = Array.from(tests); 8 | return copyTests.sort((testA, testB) => 9 | testA.path > testB.path ? 1 : -1, 10 | ); 11 | } 12 | } 13 | 14 | module.exports = CustomSequencer; 15 | -------------------------------------------------------------------------------- /tests/end-to-end/utils/constants.js: -------------------------------------------------------------------------------- 1 | const PUBLIC_URL = 2 | "/my-code/standalone-bouncer/tests/scripts/public/protected-page.php"; 3 | const APPSEC_TEST_URL = 4 | "/my-code/standalone-bouncer/tests/scripts/public/testappsec.php"; 5 | const APPSEC_UPLOAD_TEST_URL = 6 | "/my-code/standalone-bouncer/tests/scripts/public/testappsec-upload.php"; 7 | const APPSEC_MALICIOUS_BODY = "class.module.classLoader.resources."; 8 | const FORCED_TEST_FORWARDED_IP = 9 | process.env.FORCED_TEST_FORWARDED_IP !== "" 10 | ? process.env.FORCED_TEST_FORWARDED_IP 11 | : null; 12 | const GEOLOC_ENABLED = process.env.GEOLOC_ENABLED === "true"; 13 | const APPSEC_ENABLED = process.env.APPSEC_ENABLED === "true"; 14 | const STREAM_MODE = process.env.STREAM_MODE === "true"; 15 | const DEBUG_MODE = process.env.DEBUG_MODE === "true"; 16 | const GEOLOC_BAD_COUNTRY = "JP"; 17 | const JAPAN_IP = "210.249.74.42"; 18 | const FRANCE_IP = "78.119.253.85"; 19 | const WATCHER_LOGIN = "watcherLogin"; 20 | const WATCHER_PASSWORD = "watcherPassword"; 21 | const { 22 | BOUNCER_KEY, 23 | APPSEC_FALLBACK, 24 | APPSEC_ACTION, 25 | APPSEC_MAX_BODY_SIZE, 26 | DEBUG, 27 | CURRENT_IP, 28 | LAPI_URL_FROM_PLAYWRIGHT, 29 | PROXY_IP, 30 | PHP_URL, 31 | AGENT_TLS_PATH, 32 | CLEAN_CACHE_DURATION, 33 | TIMEOUT, 34 | } = process.env; 35 | const AGENT_CERT_PATH = `${AGENT_TLS_PATH}/agent.pem`; 36 | const AGENT_KEY_PATH = `${AGENT_TLS_PATH}/agent-key.pem`; 37 | const CA_CERT_PATH = `${AGENT_TLS_PATH}/ca-chain.pem`; 38 | const DEBUG_LOG_PATH = `${AGENT_TLS_PATH}/../my-code/standalone-bouncer/scripts/.logs/debug.log`; 39 | 40 | module.exports = { 41 | APPSEC_TEST_URL, 42 | APPSEC_UPLOAD_TEST_URL, 43 | APPSEC_ENABLED, 44 | APPSEC_MALICIOUS_BODY, 45 | APPSEC_FALLBACK, 46 | APPSEC_ACTION, 47 | APPSEC_MAX_BODY_SIZE, 48 | PHP_URL, 49 | BOUNCER_KEY, 50 | CLEAN_CACHE_DURATION, 51 | CURRENT_IP, 52 | DEBUG, 53 | DEBUG_MODE, 54 | DEBUG_LOG_PATH, 55 | FORCED_TEST_FORWARDED_IP, 56 | LAPI_URL_FROM_PLAYWRIGHT, 57 | PROXY_IP, 58 | PUBLIC_URL, 59 | TIMEOUT, 60 | WATCHER_LOGIN, 61 | WATCHER_PASSWORD, 62 | GEOLOC_ENABLED, 63 | GEOLOC_BAD_COUNTRY, 64 | STREAM_MODE, 65 | JAPAN_IP, 66 | FRANCE_IP, 67 | AGENT_CERT_PATH, 68 | AGENT_KEY_PATH, 69 | CA_CERT_PATH, 70 | }; 71 | -------------------------------------------------------------------------------- /tests/end-to-end/utils/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const fs = require("fs"); 3 | 4 | const { addDecision, deleteAllDecisions } = require("./watcherClient"); 5 | const { PHP_URL, TIMEOUT, PUBLIC_URL } = require("./constants"); 6 | 7 | const wait = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 8 | 9 | jest.setTimeout(TIMEOUT); 10 | 11 | const goToPublicPage = async (endpoint = PUBLIC_URL) => { 12 | return page.goto(`${PHP_URL}${endpoint}`); 13 | }; 14 | 15 | const runCacheAction = async (actionType = "refresh", otherParams = "") => { 16 | await goToPublicPage( 17 | `/my-code/standalone-bouncer/tests/scripts/public/cache-actions.php?action=${actionType}${otherParams}`, 18 | ); 19 | await page.waitForLoadState("networkidle"); 20 | await expect(page).not.toMatchTitle(/404/); 21 | await expect(page).toMatchTitle(`Cache action: ${actionType}`); 22 | }; 23 | 24 | const runGeolocationTest = async (ip, saveResult, brokenDb = false) => { 25 | let url = `/my-code/standalone-bouncer/tests/scripts/public/geolocation-test.php?ip=${ip}`; 26 | if (saveResult) { 27 | url += "&cache-duration=120"; 28 | } 29 | if (brokenDb) { 30 | url += "&broken-db=1"; 31 | } 32 | await goToPublicPage(`${url}`); 33 | await page.waitForLoadState("networkidle"); 34 | await expect(page).not.toMatchTitle(/404/); 35 | await expect(page).toMatchTitle(`Geolocation for IP: ${ip}`); 36 | }; 37 | 38 | const computeCurrentPageRemediation = async ( 39 | accessibleTextInTitle = "Home page", 40 | ) => { 41 | const title = await page.title(); 42 | if (title.includes(accessibleTextInTitle)) { 43 | return "bypass"; 44 | } 45 | await expect(title).toContain("Oops"); 46 | const description = await page.$eval(".desc", (el) => el.innerText); 47 | const banText = "cyber"; 48 | const captchaText = "check"; 49 | if (description.includes(banText)) { 50 | return "ban"; 51 | } 52 | if (description.includes(captchaText)) { 53 | return "captcha"; 54 | } 55 | 56 | throw Error("Current remediation can not be computed"); 57 | }; 58 | 59 | const publicHomepageShouldBeBanWall = async () => { 60 | await goToPublicPage(); 61 | const remediation = await computeCurrentPageRemediation(); 62 | await expect(remediation).toBe("ban"); 63 | }; 64 | 65 | const publicHomepageShouldBeCaptchaWall = async () => { 66 | await goToPublicPage(); 67 | const remediation = await computeCurrentPageRemediation(); 68 | await expect(remediation).toBe("captcha"); 69 | }; 70 | 71 | const publicHomepageShouldBeCaptchaWallWithoutMentions = async () => { 72 | await publicHomepageShouldBeCaptchaWall(); 73 | await expect(page).not.toHaveText( 74 | ".main", 75 | "This security check has been powered by", 76 | ); 77 | }; 78 | 79 | const publicHomepageShouldBeCaptchaWallWithMentions = async () => { 80 | await publicHomepageShouldBeCaptchaWall(); 81 | await expect(page).toHaveText( 82 | ".main", 83 | "This security check has been powered by", 84 | ); 85 | }; 86 | 87 | const publicHomepageShouldBeAccessible = async () => { 88 | await goToPublicPage(); 89 | const remediation = await computeCurrentPageRemediation(); 90 | await expect(remediation).toBe("bypass"); 91 | }; 92 | 93 | const banIpForSeconds = async (seconds, ip) => { 94 | await addDecision(ip, "ban", seconds); 95 | await wait(1000); 96 | }; 97 | 98 | const captchaIpForSeconds = async (seconds, ip) => { 99 | await addDecision(ip, "captcha", seconds); 100 | await wait(1000); 101 | }; 102 | 103 | const removeAllDecisions = async () => { 104 | await deleteAllDecisions(); 105 | await wait(1000); 106 | }; 107 | 108 | const getFileContent = async (filePath) => { 109 | if (fs.existsSync(filePath)) { 110 | return fs.readFileSync(filePath, "utf8"); 111 | } 112 | return ""; 113 | }; 114 | 115 | const deleteFileContent = async (filePath) => { 116 | if (fs.existsSync(filePath)) { 117 | return fs.writeFileSync(filePath, ""); 118 | } 119 | return false; 120 | }; 121 | 122 | const fillInput = async (optionId, value) => { 123 | await page.fill(`[id=${optionId}]`, `${value}`); 124 | }; 125 | const fillByName = async (name, value) => { 126 | await page.fill(`[name=${name}]`, `${value}`); 127 | }; 128 | 129 | const selectElement = async (selectId, valueToSelect) => { 130 | await page.selectOption(`[id=${selectId}]`, `${valueToSelect}`); 131 | }; 132 | 133 | const selectByName = async (selectName, valueToSelect) => { 134 | await page.selectOption(`[name=${selectName}]`, `${valueToSelect}`); 135 | }; 136 | 137 | const clickById = async (id) => { 138 | await page.click(`#${id}`); 139 | }; 140 | 141 | const getTextById = async (id) => { 142 | return page.locator(`#${id}`).innerText(); 143 | }; 144 | 145 | const getHtmlById = async (id) => { 146 | return page.locator(`#${id}`).innerHTML(); 147 | }; 148 | 149 | module.exports = { 150 | addDecision, 151 | wait, 152 | goToPublicPage, 153 | publicHomepageShouldBeBanWall, 154 | publicHomepageShouldBeCaptchaWall, 155 | publicHomepageShouldBeCaptchaWallWithoutMentions, 156 | publicHomepageShouldBeCaptchaWallWithMentions, 157 | publicHomepageShouldBeAccessible, 158 | banIpForSeconds, 159 | captchaIpForSeconds, 160 | removeAllDecisions, 161 | getFileContent, 162 | deleteFileContent, 163 | runCacheAction, 164 | runGeolocationTest, 165 | fillInput, 166 | fillByName, 167 | selectElement, 168 | selectByName, 169 | clickById, 170 | getTextById, 171 | computeCurrentPageRemediation, 172 | getHtmlById, 173 | }; 174 | -------------------------------------------------------------------------------- /tests/end-to-end/utils/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-standalone-php-bouncer/0aa1eafe40493a01ce4eee7fcef7510df9157c41/tests/end-to-end/utils/icon.png -------------------------------------------------------------------------------- /tests/end-to-end/utils/watcherClient.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios").default; 2 | const https = require("https"); 3 | const fs = require("fs"); 4 | 5 | const { 6 | LAPI_URL_FROM_PLAYWRIGHT, 7 | WATCHER_LOGIN, 8 | WATCHER_PASSWORD, 9 | AGENT_CERT_PATH, 10 | AGENT_KEY_PATH, 11 | CA_CERT_PATH, 12 | } = require("./constants"); 13 | 14 | const httpsAgent = new https.Agent({ 15 | rejectUnauthorized: true, 16 | cert: fs.readFileSync(AGENT_CERT_PATH), 17 | key: fs.readFileSync(AGENT_KEY_PATH), 18 | ca: fs.readFileSync(CA_CERT_PATH), 19 | }); 20 | 21 | const httpClient = axios.create({ 22 | baseURL: LAPI_URL_FROM_PLAYWRIGHT, 23 | timeout: 5000, 24 | httpsAgent, 25 | }); 26 | 27 | let authenticated = false; 28 | 29 | const long2ip = (properAddress) => { 30 | // Converts an (IPv4) Internet network address into a string in Internet standard dotted format 31 | // 32 | // version: 1109.2015 33 | // discuss at: http://phpjs.org/functions/long2ip 34 | // + original by: Waldo Malqui Silva 35 | // * example 1: long2ip( 3221234342 ); 36 | // * returns 1: '192.0.34.166' 37 | let output = false; 38 | if ( 39 | !Number.isNaN(properAddress) && 40 | (properAddress >= 0 || properAddress <= 4294967295) 41 | ) { 42 | output = `${Math.floor(properAddress / 256 ** 3)}.${Math.floor( 43 | (properAddress % 256 ** 3) / 256 ** 2, 44 | )}.${Math.floor( 45 | ((properAddress % 256 ** 3) % 256 ** 2) / 256 ** 1, 46 | )}.${Math.floor( 47 | (((properAddress % 256 ** 3) % 256 ** 2) % 256 ** 1) / 256 ** 0, 48 | )}`; 49 | } 50 | return output; 51 | }; 52 | 53 | const ip2long = (argIpParam) => { 54 | let i = 0; 55 | let argIP = argIpParam; 56 | 57 | const pattern = new RegExp( 58 | [ 59 | "^([1-9]\\d*|0[0-7]*|0x[\\da-f]+)", 60 | "(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?", 61 | "(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?", 62 | "(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?$", 63 | ].join(""), 64 | "i", 65 | ); 66 | argIP = argIP.match(pattern); 67 | if (!argIP) { 68 | throw new Error(`${argIpParam} is not a valid IP`); 69 | } 70 | argIP[0] = 0; 71 | for (i = 1; i < 5; i += 1) { 72 | argIP[0] += !!(argIP[i] || "").length; 73 | // eslint-disable-next-line radix 74 | argIP[i] = parseInt(argIP[i]) || 0; 75 | } 76 | argIP.push(256, 256, 256, 256); 77 | argIP[4 + argIP[0]] *= 256 ** (4 - argIP[0]); 78 | if ( 79 | argIP[1] >= argIP[5] || 80 | argIP[2] >= argIP[6] || 81 | argIP[3] >= argIP[7] || 82 | argIP[4] >= argIP[8] 83 | ) { 84 | throw new Error( 85 | `Something went wrong with ${argIpParam} ip2long process`, 86 | ); 87 | } 88 | return ( 89 | argIP[1] * (argIP[0] === 1 || 16777216) + 90 | argIP[2] * (argIP[0] <= 2 || 65536) + 91 | argIP[3] * (argIP[0] <= 3 || 256) + 92 | argIP[4] * 1 93 | ); 94 | }; 95 | 96 | const cidrToRange = (cidrParam) => { 97 | let cidr = cidrParam; 98 | const range = [2]; 99 | cidr = cidr.split("/"); 100 | // eslint-disable-next-line radix 101 | const cidr1 = parseInt(cidr[1]); 102 | // eslint-disable-next-line no-bitwise 103 | range[0] = long2ip(ip2long(cidr[0]) & (-1 << (32 - cidr1))); 104 | const start = ip2long(range[0]); 105 | range[1] = long2ip(start + 2 ** (32 - cidr1) - 1); 106 | return range; 107 | }; 108 | 109 | const auth = async () => { 110 | if (authenticated) { 111 | return; 112 | } 113 | try { 114 | const response = await httpClient.post("/v1/watchers/login", { 115 | machine_id: WATCHER_LOGIN, 116 | password: WATCHER_PASSWORD, 117 | }); 118 | 119 | httpClient.defaults.headers.common.Authorization = `Bearer ${response.data.token}`; 120 | authenticated = true; 121 | } catch (error) { 122 | console.debug( 123 | "WATCHER_LOGIN, WATCHER_PASSWORD", 124 | WATCHER_LOGIN, 125 | WATCHER_PASSWORD, 126 | ); 127 | console.error(error); 128 | } 129 | }; 130 | 131 | module.exports.addDecision = async ( 132 | value, 133 | remediation, 134 | durationInSeconds, 135 | scope = "Ip", 136 | ) => { 137 | await auth(); 138 | let finalScope = "Country"; 139 | if (["Ip", "Range"].includes(scope)) { 140 | // IPv6 141 | if (value.includes(":")) { 142 | finalScope = "Ip"; 143 | } else { 144 | let startIp; 145 | let endIp; 146 | if (value.split("/").length === 2) { 147 | [startIp, endIp] = cidrToRange(value); 148 | } else { 149 | startIp = value; 150 | endIp = value; 151 | } 152 | const startLongIp = ip2long(startIp); 153 | const endLongIp = ip2long(endIp); 154 | const isRange = startLongIp !== endLongIp; 155 | finalScope = isRange ? "Range" : "Ip"; 156 | } 157 | } 158 | const scenario = `add ${remediation} with scope/value ${scope}/${value} for ${durationInSeconds} seconds for e2e tests`; 159 | 160 | const startAt = new Date(); 161 | const stopAt = new Date(); 162 | stopAt.setTime(stopAt.getTime() + durationInSeconds * 1000); 163 | const body = [ 164 | { 165 | capacity: 0, 166 | decisions: [ 167 | { 168 | duration: `${durationInSeconds}s`, 169 | origin: "cscli", 170 | scenario, 171 | scope: finalScope, 172 | type: remediation, 173 | value, 174 | }, 175 | ], 176 | events: [], 177 | events_count: 1, 178 | labels: null, 179 | leakspeed: "0", 180 | message: scenario, 181 | scenario, 182 | scenario_hash: "", 183 | scenario_version: "", 184 | simulated: false, 185 | source: { 186 | scope: finalScope, 187 | value, 188 | }, 189 | start_at: startAt.toISOString(), 190 | stop_at: stopAt.toISOString(), 191 | }, 192 | ]; 193 | try { 194 | await httpClient.post("/v1/alerts", body); 195 | } catch (error) { 196 | console.debug(error.response); 197 | throw new Error(error); 198 | } 199 | }; 200 | 201 | module.exports.deleteAllDecisions = async () => { 202 | try { 203 | await auth(); 204 | await httpClient.delete("/v1/decisions"); 205 | } catch (error) { 206 | console.debug(error.response); 207 | throw new Error(error); 208 | } 209 | }; 210 | -------------------------------------------------------------------------------- /tests/scripts/clear-cache.php: -------------------------------------------------------------------------------- 1 | '); 16 | } 17 | echo "\nClear the cache...\n"; 18 | 19 | // Instantiate the Stream logger 20 | $logger = new Logger('example'); 21 | 22 | // Display logs with DEBUG verbosity 23 | $streamHandler = new StreamHandler('php://stdout', Logger::DEBUG); 24 | $streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); 25 | $logger->pushHandler($streamHandler); 26 | 27 | 28 | // Instantiate the bouncer 29 | $configs = [ 30 | 'api_key' => $bouncerApiKey, 31 | 'api_url' => 'https://crowdsec:8080', 32 | 'fs_cache_path' => __DIR__ . '/.cache', 33 | ]; 34 | $bouncer = new Bouncer($configs, $logger); 35 | 36 | // Clear the cache. 37 | $bouncer->clearCache(); 38 | echo "Cache successfully cleared.\n"; 39 | -------------------------------------------------------------------------------- /tests/scripts/public/cache-actions.php: -------------------------------------------------------------------------------- 1 | Cache action has been done: $action"; 26 | 27 | switch ($action) { 28 | case 'refresh': 29 | $bouncer->refreshBlocklistCache(); 30 | break; 31 | case 'clear': 32 | $bouncer->clearCache(); 33 | break; 34 | case 'prune': 35 | $bouncer->pruneCache(); 36 | break; 37 | case 'push-usage-metrics': 38 | $bouncer->pushUsageMetrics('test-'. Constants::BOUNCER_NAME. '-e2e', Constants::VERSION, 'test-crowdsec-php-bouncer'); 39 | break; 40 | case 'captcha-phrase': 41 | if(!isset($_GET['ip'])){ 42 | exit('You must pass an "ip" param to get the associated captcha phrase' . \PHP_EOL); 43 | } 44 | $ip = $_GET['ip']; 45 | $cache = $bouncer->getRemediationEngine()->getCacheStorage(); 46 | $cacheKey = $cache->getCacheKey(Constants::CACHE_TAG_CAPTCHA, $ip); 47 | $item = $cache->getItem($cacheKey); 48 | $result = "

No captcha for this IP: $ip

"; 49 | if($item->isHit()){ 50 | $cached = $item->get(); 51 | $phrase = $cached['phrase_to_guess']??"No phrase to guess for this captcha (already resolved ?)"; 52 | $result = "

$phrase

"; 53 | } 54 | break; 55 | case 'show-origins-count': 56 | $result = "

Origins count

"; 57 | $origins = $bouncer->getRemediationEngine()->getOriginsCount(); 58 | $result .= "
".json_encode($origins)."
"; 59 | break; 60 | default: 61 | throw new Exception("Unknown cache action type:$action"); 62 | } 63 | 64 | echo " 65 | 66 | 67 | 68 | 69 | Cache action: $action 70 | 71 | 72 | 73 | $result 74 | 75 | 76 | "; 77 | } else { 78 | exit('You must pass an "action" param (refresh, clear or prune)' . \PHP_EOL); 79 | } 80 | -------------------------------------------------------------------------------- /tests/scripts/public/geolocation-test.php: -------------------------------------------------------------------------------- 1 | true, 21 | 'cache_duration' => $cacheDuration, 22 | 'type' => 'maxmind', 23 | 'maxmind' => [ 24 | 'database_type' => $dbType, 25 | 'database_path' => '/var/www/html/my-code/standalone-bouncer/tests/' . $dbName, 26 | ], 27 | ]; 28 | 29 | if ($fakeBrokenDb) { 30 | $geolocConfig['maxmind']['database_path'] = '/var/www/html/my-code/standalone-bouncer/tests/broken.mmdb'; 31 | } 32 | 33 | $finalConfig = array_merge($settings, ['geolocation' => $geolocConfig]); 34 | $bouncer = new Bouncer($finalConfig); 35 | 36 | $cache = $bouncer->getRemediationEngine()->getCacheStorage(); 37 | 38 | $geolocation = new Geolocation($geolocConfig, $cache, $bouncer->getLogger()); 39 | if ($cacheDuration <= 0) { 40 | $geolocation->clearGeolocationCache($requestedIp); 41 | } 42 | 43 | $countryResult = $geolocation->handleCountryResultForIp($requestedIp); 44 | $country = $countryResult['country']; 45 | $notFound = $countryResult['not_found']; 46 | $error = $countryResult['error']; 47 | 48 | echo " 49 | 50 | 51 | 52 | 53 | Geolocation for IP: $requestedIp 54 | 55 | 56 | 57 |

For IP $requestedIp:

58 |
    59 |
  • Country: $country
  • 60 |
  • Not Found message: $notFound
  • 61 |
  • Error message: $error
  • 62 |
  • Cache duration: $cacheDuration
  • 63 |
64 | 65 | 66 | "; 67 | } else { 68 | exit('You must pass an "ip" param' . \PHP_EOL); 69 | } 70 | -------------------------------------------------------------------------------- /tests/scripts/public/protected-page.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | Home page 9 | 28 | 29 | 30 | 31 |

The way is clear!

32 |

In this example page, if you can see this text, the bouncer considers your IP as clean.

33 | 34 | 35 |

Use the below form to send a POST request to the current url.

36 | 37 | 38 | 39 | 40 | 41 |
INITIAL STATE
42 | 43 | 44 | '; 45 | -------------------------------------------------------------------------------- /tests/scripts/public/testappsec-upload.php: -------------------------------------------------------------------------------- 1 | true, 'filepath' => $uploadFile]; 19 | } else { 20 | $response = ['success' => false, 'message' => 'Failed to move uploaded file.']; 21 | } 22 | } else { 23 | $response = ['success' => false, 'message' => 'No image uploaded or there was an error uploading the file.']; 24 | } 25 | 26 | echo json_encode($response); // Output the JSON response 27 | exit; // Prevent further rendering of the page 28 | } 29 | 30 | // If it's not a POST request, render the HTML page as normal 31 | ?> 32 | 33 | 34 | 35 | 36 | 37 | 38 | Image Upload 39 | 40 | 41 |

Upload an Image

42 | 43 |
44 | 45 | 46 |
47 | 48 |
INITIAL STATE
49 | 50 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /tests/scripts/public/testappsec.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | Test AppSec 9 | 10 | 11 | 12 |

For AppSec test

13 |

In this example page, if you can see this text, AppSec considers your request as clean.

14 | 15 | 16 | '; 17 | -------------------------------------------------------------------------------- /tests/scripts/standalone-check-ip-live.php: -------------------------------------------------------------------------------- 1 | '); 17 | } 18 | // Instantiate the Stream logger 19 | $logger = new Logger('example'); 20 | 21 | // Display logs with DEBUG verbosity 22 | $streamHandler = new StreamHandler('php://stdout', Logger::DEBUG); 23 | $streamHandler->setFormatter(new LineFormatter("[%datetime%] %message% %context%\n")); 24 | $logger->pushHandler($streamHandler); 25 | 26 | // Init 27 | $configs = [ 28 | 'api_key' => $bouncerKey, 29 | 'api_url' => 'https://crowdsec:8080', 30 | 'fs_cache_path' => __DIR__ . '/.cache', 31 | 'stream_mode' => false 32 | ]; 33 | $bouncer = new Bouncer($configs, $logger); 34 | 35 | // Ask remediation to LAPI 36 | 37 | echo "\nVerify $requestedIp...\n"; 38 | $remediation = $bouncer->getRemediationForIp($requestedIp); 39 | echo "\nResult: $remediation\n\n"; // "ban", "captcha" or "bypass" 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tools/coding-standards/.gitignore: -------------------------------------------------------------------------------- 1 | #Tools 2 | .php-cs-fixer.cache 3 | .php-cs-fixer.php 4 | .phpunit.cache 5 | *.html 6 | *.css 7 | *.js 8 | *.svg 9 | *.txt 10 | -------------------------------------------------------------------------------- /tools/coding-standards/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": ">=7.4", 4 | "friendsofphp/php-cs-fixer": "^v3.8.0", 5 | "phpstan/phpstan": "^1.8.0", 6 | "phpmd/phpmd": "^2.12.0", 7 | "squizlabs/php_codesniffer": "3.7.1", 8 | "vimeo/psalm": "^4.24.0 || ^5.26.0", 9 | "nikic/php-parser": "^4.18", 10 | "phpunit/phpunit": "^9.3", 11 | "phpunit/php-code-coverage": "^9.2.15", 12 | "mikey179/vfsstream": "^1.6.11", 13 | "crowdsec/standalone-bouncer": "@dev" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "CrowdSecStandalone\\Tests\\": "../../tests/" 18 | } 19 | }, 20 | "repositories": { 21 | "crowdsec-standalone-bouncer": { 22 | "type": "path", 23 | "url": "../../" 24 | } 25 | }, 26 | "config": { 27 | "allow-plugins": { 28 | "cweagans/composer-patches": true 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tools/coding-standards/php-cs-fixer/.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRules([ 10 | '@Symfony' => true, 11 | '@PSR12:risky' => true, 12 | '@PHPUnit75Migration:risky' => true, 13 | 'array_syntax' => ['syntax' => 'short'], 14 | 'fopen_flags' => false, 15 | 'protected_to_private' => false, 16 | 'native_constant_invocation' => true, 17 | 'combine_nested_dirname' => true, 18 | 'phpdoc_to_comment' => false, 19 | 'concat_space' => ['spacing'=> 'one'], 20 | ]) 21 | ->setRiskyAllowed(true) 22 | ->setFinder( 23 | PhpCsFixer\Finder::create() 24 | ->in(__DIR__ . '/../../../src') 25 | ->in(__DIR__ . '/../../../scripts') 26 | ->in(__DIR__ . '/../../../tests')->exclude(['end-to-end','scripts']) 27 | ) 28 | ; -------------------------------------------------------------------------------- /tools/coding-standards/phpmd/rulesets.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | Rule set that checks CrowdSec Standalone PHP Bouncer 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tools/coding-standards/phpstan/phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | bootstrapFiles: 3 | - ../../../vendor/autoload.php -------------------------------------------------------------------------------- /tools/coding-standards/phpunit/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | ../../../tests/Unit 19 | 20 | 21 | ../../../tests/Integration 22 | 23 | 24 | 25 | 26 | 27 | ../../../src 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tools/coding-standards/psalm/psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | --------------------------------------------------------------------------------