├── .githooks └── commit-msg ├── .github ├── dependabot.yml └── workflows │ ├── coding-standards.yml │ ├── doc-links.yml │ ├── keepalive.yml │ ├── php-sdk-development-tests.yml │ ├── release.yml │ ├── sdk-chain-tests.yml │ └── test-suite.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── DEVELOPER.md ├── INSTALLATION_GUIDE.md ├── TECHNICAL_NOTES.md ├── USER_GUIDE.md └── images │ ├── logo_crowdsec.png │ └── screenshots │ ├── front-ban.jpg │ └── front-captcha.jpg ├── src ├── AbstractBouncer.php ├── BouncerException.php ├── Configuration.php ├── Constants.php ├── Helper.php ├── Template.php └── templates │ ├── ban.html.twig │ ├── captcha.html.twig │ └── partials │ ├── bottom.html.twig │ ├── captcha-js.html.twig │ ├── head.html.twig │ └── mentions.html.twig ├── tests ├── Integration │ ├── AbstractBouncerTest.php │ ├── GeolocationTest.php │ ├── TestHelpers.php │ └── WatcherClient.php ├── PHPUnitUtil.php └── Unit │ ├── AbstractBouncerTest.php │ ├── FailingStreamWrapper.php │ ├── HelperTest.php │ └── TemplateTest.php └── tools ├── .gitignore └── coding-standards ├── 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/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: phpunit/phpunit 10 | -------------------------------------------------------------------------------- /.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/crowdsec-bouncer-lib" 34 | 35 | steps: 36 | 37 | - name: Install DDEV 38 | # @see https://ddev.readthedocs.io/en/stable/#installationupgrade-script-linux-and-macos-armarm64-and-amd64-architectures 39 | run: | 40 | curl -fsSL https://apt.fury.io/drud/gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/ddev.gpg > /dev/null 41 | 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 42 | sudo apt-get -q update 43 | sudo apt-get -q -y install libnss3-tools ddev 44 | mkcert -install 45 | ddev config global --instrumentation-opt-in=false --omit-containers=ddev-ssh-agent 46 | 47 | - name: Create empty PHP DDEV project 48 | run: ddev config --project-type=php --project-name=crowdsec-bouncer-lib --php-version=${{ matrix.php-version }} 49 | 50 | - name: Add Redis, Memcached and Crowdsec 51 | run: | 52 | ddev get ddev/ddev-redis 53 | ddev get ddev/ddev-memcached 54 | # override redis.conf 55 | ddev get julienloizelet/ddev-tools 56 | ddev get julienloizelet/ddev-crowdsec-php 57 | 58 | - name: Start DDEV 59 | run: | 60 | ddev start 61 | 62 | - name: Some DEBUG information 63 | run: | 64 | ddev --version 65 | ddev exec php -v 66 | ddev php -r "echo phpversion('memcached');" 67 | 68 | - name: Clone PHP lib Crowdsec files 69 | uses: actions/checkout@v4 70 | with: 71 | path: my-code/crowdsec-bouncer-lib 72 | 73 | - name: Install CrowdSec lib dependencies 74 | run: ddev composer update --working-dir ./${{env.EXTENSION_PATH}} 75 | 76 | - name: Install Coding standards tools 77 | run: ddev composer update --working-dir=./${{env.EXTENSION_PATH}}/tools/coding-standards 78 | 79 | - name: Run PHPCS 80 | run: ddev phpcs ./${{env.EXTENSION_PATH}}/tools/coding-standards ${{env.EXTENSION_PATH}}/src PSR12 81 | 82 | - name: Run PHPSTAN 83 | run: ddev phpstan /var/www/html/${{env.EXTENSION_PATH}}/tools/coding-standards phpstan/phpstan.neon /var/www/html/${{env.EXTENSION_PATH}}/src 84 | 85 | - name: Run PHPMD 86 | run: ddev phpmd ./${{env.EXTENSION_PATH}}/tools/coding-standards phpmd/rulesets.xml ../../src 87 | 88 | - name: Run PSALM 89 | if: contains(fromJson('["7.4","8.0","8.1","8.2","8.3"]'),matrix.php-version) 90 | run: ddev psalm ./${{env.EXTENSION_PATH}}/tools/coding-standards ./${{env.EXTENSION_PATH}}/tools/coding-standards/psalm 91 | 92 | - name: Prepare for Code Coverage 93 | if: github.event.inputs.coverage_report == 'true' 94 | run: | 95 | mkdir ${{ github.workspace }}/cfssl 96 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl 97 | ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests 98 | ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests 99 | cd ${{env.EXTENSION_PATH}}/tests 100 | sha256sum -c GeoLite2-Country.tar.gz.sha256.txt 101 | sha256sum -c GeoLite2-City.tar.gz.sha256.txt 102 | tar -xf GeoLite2-Country.tar.gz 103 | tar -xf GeoLite2-City.tar.gz 104 | rm GeoLite2-Country.tar.gz GeoLite2-Country.tar.gz.sha256.txt GeoLite2-City.tar.gz GeoLite2-City.tar.gz.sha256.txt 105 | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV 106 | 107 | - name: Run PHPUNIT Code Coverage 108 | if: github.event.inputs.coverage_report == 'true' 109 | run: | 110 | ddev xdebug 111 | 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 112 | cat ${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt 113 | -------------------------------------------------------------------------------- /.github/workflows/doc-links.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | name: Documentation links check 14 | jobs: 15 | markdown-test: 16 | name: Markdown files test 17 | runs-on: ubuntu-latest 18 | steps: 19 | 20 | - name: Clone sources 21 | uses: actions/checkout@v4 22 | with: 23 | path: extension 24 | 25 | - name: Launch localhost server 26 | run: | 27 | sudo npm install --global http-server 28 | http-server ./extension & 29 | 30 | - name: Set up Ruby 2.6 31 | uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: 2.6 34 | 35 | 36 | - name: Check links in Markdown files 37 | run: | 38 | gem install awesome_bot 39 | cd extension 40 | awesome_bot --files README.md --allow-dupe --allow 401,301 --skip-save-results --white-list ddev.site --base-url http://localhost:8080/ 41 | awesome_bot docs/*.md --skip-save-results --allow-dupe --allow 401,301 --white-list ddev.site,https://crowdsec,http://crowdsec,php.net/supported-versions.php,https://www.maxmind.com --base-url http://localhost:8080/docs/ 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/keepalive.yml: -------------------------------------------------------------------------------- 1 | name: Keep Alive 2 | on: 3 | 4 | schedule: 5 | - cron: '0 3 * * 4' 6 | 7 | permissions: 8 | actions: write 9 | 10 | jobs: 11 | keep-alive: 12 | 13 | name: Keep Alive 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | 18 | - name: Clone project files 19 | uses: actions/checkout@v4 20 | 21 | # keepalive-workflow keeps GitHub from turning off tests after 60 days 22 | - uses: gautamkrishnar/keepalive-workflow@v2 23 | with: 24 | time_elapsed: 45 25 | workflow_files: "test-suite.yml" 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/php-sdk-development-tests.yml: -------------------------------------------------------------------------------- 1 | name: PHP SDK development tests 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | php_common_json: 6 | type: string 7 | description: The PHP common json to use 8 | required: true 9 | default: '["main","crowdsecurity/php-common"]' 10 | lapi_client_json: 11 | type: string 12 | description: The LAPI client json to use 13 | required: true 14 | default: '["main","crowdsecurity/php-lapi-client"]' 15 | capi_client_json: 16 | type: string 17 | description: The CAPI client json to use 18 | required: true 19 | default: '["main","crowdsecurity/php-capi-client"]' 20 | remediation_engine_json: 21 | type: string 22 | description: The Remediation Engine json to use 23 | required: true 24 | default: '["main", "crowdsecurity/php-remediation-engine"]' 25 | 26 | workflow_call: 27 | # For workflow_call, we don't allow passing a repository as input 28 | inputs: 29 | is_call: 30 | type: boolean 31 | description: "Flag to indicate if the workflow is called" 32 | # @see https://github.com/actions/runner/discussions/1884 33 | required: false 34 | default: true 35 | php_common_json: 36 | type: string 37 | description: The PHP common json ('["branch"]') 38 | required: true 39 | lapi_client_json: 40 | type: string 41 | description: The LAPI client json ('["branch"]') 42 | required: true 43 | capi_client_json: 44 | type: string 45 | description: The CAPI client json ('["branch"]') 46 | required: true 47 | remediation_engine_json: 48 | type: string 49 | description: The Remediation Engine json ('["branch"]') 50 | required: true 51 | 52 | permissions: 53 | contents: read 54 | 55 | env: 56 | # Allow ddev get to use a GitHub token to prevent rate limiting by tests 57 | DDEV_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | STANDALONE_BOUNCER_REPO: crowdsecurity/cs-standalone-php-bouncer 59 | BOUNCER_LIB_REPO: crowdsecurity/php-cs-bouncer 60 | REMEDIATION_ENGINE_REPO: crowdsecurity/php-remediation-engine 61 | CAPI_CLIENT_REPO: crowdsecurity/php-capi-client 62 | LAPI_CLIENT_REPO: crowdsecurity/php-lapi-client 63 | PHP_COMMON_REPO: crowdsecurity/php-common 64 | PHP_COMMON_JSON: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.php_common_json || inputs.php_common_json }} 65 | LAPI_CLIENT_JSON: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.lapi_client_json || inputs.lapi_client_json }} 66 | REMEDIATION_ENGINE_JSON: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.remediation_engine_json || inputs.remediation_engine_json }} 67 | CAPI_CLIENT_JSON: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.capi_client_json || inputs.capi_client_json }} 68 | 69 | jobs: 70 | test-suite: 71 | strategy: 72 | fail-fast: false 73 | matrix: 74 | php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] 75 | 76 | name: Test suite 77 | runs-on: ubuntu-20.04 78 | if: ${{ !contains(github.event.head_commit.message, 'chore(') }} 79 | env: 80 | EXTENSION_PATH: "my-code/crowdsec-bouncer-lib" 81 | REMEDIATION_ENGINE_PATH: "my-code/php-remediation-engine" 82 | CAPI_CLIENT_PATH: "my-code/php-capi-client" 83 | LAPI_CLIENT_PATH: "my-code/php-lapi-client" 84 | PHP_COMMON_PATH: "my-code/php-common" 85 | DDEV_PROJECT: "crowdsec-bouncer-lib" 86 | JP_TEST_IP: "210.249.74.42" 87 | IPV6_TEST_IP: "2001:0db8:0000:85a3:0000:0000:ac1f:8001" 88 | IPV6_TEST_PROXY_IP: "2345:0425:2CA1:0000:0000:0567:5673:23b5" 89 | 90 | steps: 91 | - name: Set PHP common variables 92 | id: set-common-data 93 | run: | 94 | echo "branch=${{ fromJson(env.PHP_COMMON_JSON)[0] }}" >> $GITHUB_OUTPUT 95 | if [ "${{ inputs.is_call }}" = "true" ]; then 96 | echo "repo=${{env.PHP_COMMON_REPO}}" >> $GITHUB_OUTPUT 97 | else 98 | echo "repo=${{ fromJson(env.PHP_COMMON_JSON)[1] }}" >> $GITHUB_OUTPUT 99 | fi 100 | 101 | - name: Set LAPI client variables 102 | id: set-lapi-client-data 103 | run: | 104 | echo "branch=${{ fromJson(env.LAPI_CLIENT_JSON)[0] }}" >> $GITHUB_OUTPUT 105 | if [ "${{ inputs.is_call }}" = "true" ]; then 106 | echo "repo=${{env.LAPI_CLIENT_REPO}}" >> $GITHUB_OUTPUT 107 | else 108 | echo "repo=${{ fromJson(env.LAPI_CLIENT_JSON)[1] }}" >> $GITHUB_OUTPUT 109 | fi 110 | 111 | - name: Set CAPI client variables 112 | id: set-capi-client-data 113 | run: | 114 | echo "branch=${{ fromJson(env.CAPI_CLIENT_JSON)[0] }}" >> $GITHUB_OUTPUT 115 | if [ "${{ inputs.is_call }}" = "true" ]; then 116 | echo "repo=${{env.CAPI_CLIENT_REPO}}" >> $GITHUB_OUTPUT 117 | else 118 | echo "repo=${{ fromJson(env.CAPI_CLIENT_JSON)[1] }}" >> $GITHUB_OUTPUT 119 | fi 120 | 121 | - name: Set Remediation engine variables 122 | id: set-remediation-engine-data 123 | run: | 124 | echo "branch=${{ fromJson(env.REMEDIATION_ENGINE_JSON)[0] }}" >> $GITHUB_OUTPUT 125 | if [ "${{ inputs.is_call }}" = "true" ]; then 126 | echo "repo=${{env.REMEDIATION_ENGINE_REPO}}" >> $GITHUB_OUTPUT 127 | else 128 | echo "repo=${{ fromJson(env.REMEDIATION_ENGINE_JSON)[1] }}" >> $GITHUB_OUTPUT 129 | fi 130 | 131 | - name: Install DDEV 132 | # @see https://ddev.readthedocs.io/en/stable/#installationupgrade-script-linux-and-macos-armarm64-and-amd64-architectures 133 | run: | 134 | curl -fsSL https://apt.fury.io/drud/gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/ddev.gpg > /dev/null 135 | 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 136 | sudo apt-get -q update 137 | sudo apt-get -q -y install libnss3-tools ddev 138 | mkcert -install 139 | ddev config global --instrumentation-opt-in=false --omit-containers=ddev-ssh-agent 140 | 141 | - name: Create empty PHP DDEV project 142 | run: ddev config --project-type=php --project-name=${{env.DDEV_PROJECT}} --php-version=${{ matrix.php-version }} 143 | 144 | - name: Add Redis, Memcached and Crowdsec 145 | run: | 146 | ddev add-on get ddev/ddev-redis 147 | ddev add-on get ddev/ddev-memcached 148 | # override redis.conf 149 | ddev add-on get julienloizelet/ddev-tools 150 | ddev add-on get julienloizelet/ddev-crowdsec-php 151 | 152 | - name: Start DDEV 153 | uses: nick-fields/retry@v3 154 | with: 155 | timeout_minutes: 5 156 | max_attempts: 3 157 | shell: bash 158 | command: ddev start 159 | 160 | - name: Set BOUNCER_KEY and PROXY_IP env 161 | run: | 162 | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV 163 | echo "PROXY_IP=$(ddev find-ip ddev-router)" >> $GITHUB_ENV 164 | 165 | - name: Some DEBUG information 166 | run: | 167 | ddev --version 168 | ddev exec php -v 169 | ddev exec -s crowdsec crowdsec -version 170 | ddev php -r "echo phpversion('memcached');" 171 | 172 | - name: Clone bouncer lib files 173 | if: inputs.is_call != true 174 | uses: actions/checkout@v4 175 | with: 176 | path: ${{env.EXTENSION_PATH}} 177 | 178 | - name: Clone bouncer lib files 179 | if: inputs.is_call == true 180 | uses: actions/checkout@v4 181 | with: 182 | repository: ${{ env.BOUNCER_LIB_REPO }} 183 | path: ${{env.EXTENSION_PATH}} 184 | ref: "main" 185 | 186 | - name: Clone PHP common files 187 | uses: actions/checkout@v4 188 | with: 189 | repository: ${{ steps.set-common-data.outputs.repo}} 190 | ref: ${{ steps.set-common-data.outputs.branch }} 191 | path: ${{env.PHP_COMMON_PATH}} 192 | 193 | - name: Clone LAPI client 194 | uses: actions/checkout@v4 195 | with: 196 | repository: ${{ steps.set-lapi-client-data.outputs.repo }} 197 | ref: ${{ steps.set-lapi-client-data.outputs.branch }} 198 | path: ${{env.LAPI_CLIENT_PATH}} 199 | 200 | - name: Clone CAPI client 201 | uses: actions/checkout@v4 202 | with: 203 | repository: ${{ steps.set-capi-client-data.outputs.repo }} 204 | ref: ${{ steps.set-capi-client-data.outputs.branch }} 205 | path: ${{env.CAPI_CLIENT_PATH}} 206 | 207 | - name: Clone PHP remediation engine 208 | uses: actions/checkout@v4 209 | with: 210 | repository: ${{ steps.set-remediation-engine-data.outputs.repo }} 211 | ref: ${{ steps.set-remediation-engine-data.outputs.branch }} 212 | path: ${{env.REMEDIATION_ENGINE_PATH}} 213 | 214 | - name: Add local repositories to composer 215 | run: | 216 | # Bouncer lib 217 | ddev exec --raw composer config repositories.0 '{"type": "path", "url": "../php-common", "options": {"symlink": true}}' --working-dir ./${{ env.EXTENSION_PATH }} 218 | ddev exec --raw composer config repositories.1 '{"type": "path", "url": "../php-lapi-client", "options": {"symlink": true}}' --working-dir ./${{ env.EXTENSION_PATH }} 219 | ddev exec --raw composer config repositories.2 '{"type": "path", "url": "../php-remediation-engine", "options": {"symlink": true}}' --working-dir ./${{ env.EXTENSION_PATH }} 220 | ddev exec --raw composer config repositories.3 '{"type": "path", "url": "../php-capi-client", "options": {"symlink": true}}' --working-dir ./${{ env.EXTENSION_PATH }} 221 | 222 | - name: Modify dependencies to use development aliases 223 | run: | 224 | # Bouncer lib 225 | ddev exec --raw composer require crowdsec/common:"dev-${{ steps.set-common-data.outputs.branch }}" --no-update --working-dir ./${{env.EXTENSION_PATH}} 226 | ddev exec --raw composer require crowdsec/lapi-client:"dev-${{ steps.set-lapi-client-data.outputs.branch }}" --no-update --working-dir ./${{env.EXTENSION_PATH}} 227 | ddev exec --raw composer require crowdsec/remediation-engine:"dev-${{ steps.set-remediation-engine-data.outputs.branch }}" --no-update --working-dir ./${{env.EXTENSION_PATH}} 228 | ddev exec --raw composer require crowdsec/capi-client:"dev-${{ steps.set-capi-client-data.outputs.branch }}" --no-update --working-dir ./${{env.EXTENSION_PATH}} 229 | # Remediation engine 230 | ddev exec --raw composer require crowdsec/common:"dev-${{ steps.set-common-data.outputs.branch }}" --no-update --working-dir ./${{env.REMEDIATION_ENGINE_PATH}} 231 | ddev exec --raw composer require crowdsec/lapi-client:"dev-${{ steps.set-lapi-client-data.outputs.branch }}" --no-update --working-dir ./${{env.REMEDIATION_ENGINE_PATH}} 232 | ddev exec --raw composer require crowdsec/capi-client:"dev-${{ steps.set-capi-client-data.outputs.branch }}" --no-update --working-dir ./${{env.REMEDIATION_ENGINE_PATH}} 233 | # CAPI client 234 | ddev exec --raw composer require crowdsec/common:"dev-${{ steps.set-common-data.outputs.branch }}" --no-update --working-dir ./${{env.CAPI_CLIENT_PATH}} 235 | # LAPI client 236 | ddev exec --raw composer require crowdsec/common:"dev-${{ steps.set-common-data.outputs.branch }}" --no-update --working-dir ./${{env.LAPI_CLIENT_PATH}} 237 | 238 | - name: Validate composer.json 239 | run: | 240 | # Bouncer lib 241 | cat ./${{env.EXTENSION_PATH}}/composer.json 242 | ddev composer validate --strict --working-dir ./${{env.EXTENSION_PATH}} 243 | # Remediation engine 244 | cat ./${{env.REMEDIATION_ENGINE_PATH}}/composer.json 245 | ddev composer validate --strict --working-dir ./${{env.REMEDIATION_ENGINE_PATH}} 246 | # CAPI client 247 | cat ./${{env.CAPI_CLIENT_PATH}}/composer.json 248 | ddev composer validate --strict --working-dir ./${{env.CAPI_CLIENT_PATH}} 249 | # LAPI client 250 | cat ./${{env.LAPI_CLIENT_PATH}}/composer.json 251 | ddev composer validate --strict --working-dir ./${{env.LAPI_CLIENT_PATH}} 252 | 253 | - name: Install CrowdSec lib dependencies 254 | run: | 255 | ddev composer update --working-dir ./${{env.EXTENSION_PATH}} 256 | 257 | - name: Check installed packages versions 258 | run: | 259 | 260 | PHP_COMMON_VERSION=$(ddev composer show crowdsec/common --working-dir ./${{env.EXTENSION_PATH}} | grep -oP "versions : \* \K(.*)") 261 | if [[ $PHP_COMMON_VERSION == "dev-${{ steps.set-common-data.outputs.branch }}" ]] 262 | then 263 | echo "PHP_COMMON_VERSION COMPARISON OK" 264 | else 265 | echo "PHP_COMMON_VERSION COMPARISON KO" 266 | echo $PHP_COMMON_VERSION 267 | exit 1 268 | fi 269 | LAPI_CLIENT_VERSION=$(ddev composer show crowdsec/lapi-client --working-dir ./${{env.EXTENSION_PATH}} | grep -oP "versions : \* \K(.*)") 270 | if [[ $LAPI_CLIENT_VERSION == "dev-${{ steps.set-lapi-client-data.outputs.branch }}" ]] 271 | then 272 | echo "LAPI_CLIENT_VERSION COMPARISON OK" 273 | else 274 | echo "LAPI_CLIENT_VERSION COMPARISON KO" 275 | echo $LAPI_CLIENT_VERSION 276 | exit 1 277 | fi 278 | CAPI_CLIENT_VERSION=$(ddev composer show crowdsec/capi-client --working-dir ./${{env.EXTENSION_PATH}} | grep -oP "versions : \* \K(.*)") 279 | if [[ $CAPI_CLIENT_VERSION == "dev-${{ steps.set-capi-client-data.outputs.branch }}" ]] 280 | then 281 | echo "CAPI_CLIENT_VERSION COMPARISON OK" 282 | else 283 | echo "CAPI_CLIENT_VERSION COMPARISON KO" 284 | echo $CAPI_CLIENT_VERSION 285 | exit 1 286 | fi 287 | REMEDIATION_ENGINE_VERSION=$(ddev composer show crowdsec/remediation-engine --working-dir ./${{env.EXTENSION_PATH}} | grep -oP "versions : \* \K(.*)") 288 | if [[ $REMEDIATION_ENGINE_VERSION == "dev-${{ steps.set-remediation-engine-data.outputs.branch }}" ]] 289 | then 290 | echo "REMEDIATION_ENGINE_VERSION COMPARISON OK" 291 | else 292 | echo "REMEDIATION_ENGINE_VERSION COMPARISON KO" 293 | echo $REMEDIATION_ENGINE_VERSION 294 | exit 1 295 | fi 296 | 297 | - name: Set excluded groups 298 | id: set-excluded-groups 299 | if: contains(fromJson('["7.2","7.3"]'),matrix.php-version) 300 | run: echo "exclude_group=$(echo --exclude-group up-to-php74 )" >> $GITHUB_OUTPUT 301 | 302 | - name: Run "Unit Tests" 303 | run: | 304 | ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox ${{ steps.set-excluded-groups.outputs.exclude_group }} ./${{env.EXTENSION_PATH}}/tests/Unit 305 | 306 | - name: Prepare PHP Integration tests 307 | run: | 308 | mkdir ${{ github.workspace }}/cfssl 309 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl 310 | ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests 311 | ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests 312 | cd ${{env.EXTENSION_PATH}}/tests 313 | sha256sum -c GeoLite2-Country.tar.gz.sha256.txt 314 | sha256sum -c GeoLite2-City.tar.gz.sha256.txt 315 | tar -xf GeoLite2-Country.tar.gz 316 | tar -xf GeoLite2-City.tar.gz 317 | rm GeoLite2-Country.tar.gz GeoLite2-Country.tar.gz.sha256.txt GeoLite2-City.tar.gz GeoLite2-City.tar.gz.sha256.txt 318 | 319 | - name: Run "IP verification" test 320 | run: | 321 | ddev exec BOUNCER_KEY=${{ env.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 ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/AbstractBouncerTest.php 322 | 323 | - name: Run "IP verification with TLS" test 324 | run: | 325 | ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} 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 ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/AbstractBouncerTest.php 326 | 327 | - name: Run "Geolocation with cURL" test 328 | run: | 329 | ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php 330 | -------------------------------------------------------------------------------- /.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 | 11 | jobs: 12 | prepare-release: 13 | name: Prepare release 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check naming convention 18 | run: | 19 | VERIF=$(echo ${{ github.event.inputs.tag_name }} | grep -E "^v([0-9]{1,}\.)([0-9]{1,}\.)([0-9]{1,})(-(alpha|beta)\.[0-9]{1,})?$") 20 | if [ ! ${VERIF} ] 21 | then 22 | echo "Tag name '${{ github.event.inputs.tag_name }}' does not comply with naming convention vX.Y.Z" 23 | exit 1 24 | fi 25 | 26 | - name: Set version number without v 27 | run: | 28 | echo "VERSION_NUMBER=$(echo ${{ github.event.inputs.tag_name }} | sed 's/v//g' )" >> $GITHUB_ENV 29 | 30 | - name: Clone sources 31 | uses: actions/checkout@v4 32 | 33 | - name: Check version ${{ env.VERSION_NUMBER }} consistency in files 34 | # Check src/Constants.php and CHANGELOG.md 35 | run: | 36 | # Check public const VERSION = 'vVERSION_NUMBER'; in CHANGELOG.md in src/Constants.php 37 | CONSTANT_VERSION=$(grep -E "public const VERSION = 'v(.*)';" src/Constants.php | sed 's/ //g') 38 | if [[ $CONSTANT_VERSION == "publicconstVERSION='v${{ env.VERSION_NUMBER }}';" ]] 39 | then 40 | echo "CONSTANT VERSION OK" 41 | else 42 | echo "CONSTANT VERSION KO" 43 | exit 1 44 | fi 45 | 46 | # Check top ## [VERSION_NUMBER](GITHUB_URL/releases/tag/vVERSION_NUMBER) - yyyy-mm-dd in CHANGELOG.md 47 | CURRENT_DATE=$(date +'%Y-%m-%d') 48 | echo $CURRENT_DATE 49 | CHANGELOG_VERSION=$(grep -o -E "## \[(.*)\].* - $CURRENT_DATE" CHANGELOG.md | head -1 | sed 's/ //g') 50 | echo $CHANGELOG_VERSION 51 | if [[ $CHANGELOG_VERSION == "##[${{ env.VERSION_NUMBER }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v${{ env.VERSION_NUMBER }})-$CURRENT_DATE" ]] 52 | then 53 | echo "CHANGELOG VERSION OK" 54 | else 55 | echo "CHANGELOG VERSION KO" 56 | exit 1 57 | fi 58 | 59 | # Check top [_Compare with previous release_](GITHUB_URL/compare/vLAST_TAG...vVERSION_NUMBER) in CHANGELOG.md 60 | COMPARISON=$(grep -oP "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/\K(.*)$" CHANGELOG.md | head -1) 61 | LAST_TAG=$(curl -Ls -o /dev/null -w %{url_effective} $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/latest | grep -oP "\/tag\/\K(.*)$") 62 | if [[ $COMPARISON == "$LAST_TAG...v${{ env.VERSION_NUMBER }})" ]] 63 | then 64 | echo "VERSION COMPARISON OK" 65 | else 66 | echo "VERSION COMPARISON KO" 67 | echo $COMPARISON 68 | echo "$LAST_TAG...v${{ env.VERSION_NUMBER }})" 69 | exit 1 70 | fi 71 | 72 | - name: Create Tag ${{ github.event.inputs.tag_name }} 73 | uses: actions/github-script@v7 74 | with: 75 | github-token: ${{ github.token }} 76 | script: | 77 | github.rest.git.createRef({ 78 | owner: context.repo.owner, 79 | repo: context.repo.repo, 80 | ref: "refs/tags/${{ github.event.inputs.tag_name }}", 81 | sha: context.sha 82 | }) 83 | 84 | - name: Prepare release notes 85 | run: | 86 | 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') 87 | echo "$VERSION_RELEASE_NOTES" >> CHANGELOG.txt 88 | 89 | - name: Create release ${{ env.VERSION_NUMBER }} 90 | uses: softprops/action-gh-release@v2 91 | with: 92 | body_path: CHANGELOG.txt 93 | name: ${{ env.VERSION_NUMBER }} 94 | tag_name: ${{ github.event.inputs.tag_name }} 95 | draft: ${{ github.event.inputs.draft }} 96 | prerelease: ${{ github.event.inputs.prerelease }} 97 | -------------------------------------------------------------------------------- /.github/workflows/sdk-chain-tests.yml: -------------------------------------------------------------------------------- 1 | name: SDK chain tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "**.md" 7 | 8 | permissions: 9 | contents: read 10 | 11 | env: 12 | # Allow ddev get to use a GitHub token to prevent rate limiting by tests 13 | DDEV_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | 15 | jobs: 16 | test-standalone-bouncer: 17 | name: Run Standalone Bouncer tests 18 | if: ${{ !contains(github.event.head_commit.message, 'chore(') }} 19 | uses: crowdsecurity/cs-standalone-php-bouncer/.github/workflows/php-sdk-development-tests.yml@main 20 | with: 21 | php_common_json: '["main"]' 22 | lapi_client_json: '["main"]' 23 | capi_client_json: '["main"]' 24 | remediation_engine_json: '["main"]' 25 | bouncer_lib_json: '["${{ github.ref_name }}"]' 26 | -------------------------------------------------------------------------------- /.github/workflows/test-suite.yml: -------------------------------------------------------------------------------- 1 | name: Test suite 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - '**.md' 8 | workflow_dispatch: 9 | schedule: 10 | - cron: '15 3 * * 4' 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | # Allow ddev get to use a GitHub token to prevent rate limiting by tests 17 | DDEV_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | jobs: 20 | test-suite: 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | php-version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] 25 | 26 | name: Test suite 27 | runs-on: ubuntu-latest 28 | if: ${{ !contains(github.event.head_commit.message, 'chore(') }} 29 | env: 30 | EXTENSION_PATH: "my-code/crowdsec-bouncer-lib" 31 | DDEV_PROJECT: "crowdsec-bouncer-lib" 32 | JP_TEST_IP: "210.249.74.42" 33 | IPV6_TEST_IP: "2001:0db8:0000:85a3:0000:0000:ac1f:8001" 34 | IPV6_TEST_PROXY_IP: "2345:0425:2CA1:0000:0000:0567:5673:23b5" 35 | 36 | steps: 37 | - name: Install DDEV 38 | # @see https://ddev.readthedocs.io/en/stable/#installationupgrade-script-linux-and-macos-armarm64-and-amd64-architectures 39 | run: | 40 | curl -fsSL https://apt.fury.io/drud/gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/ddev.gpg > /dev/null 41 | 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 42 | sudo apt-get -q update 43 | sudo apt-get -q -y install libnss3-tools ddev 44 | mkcert -install 45 | ddev config global --instrumentation-opt-in=false --omit-containers=ddev-ssh-agent 46 | 47 | - name: Create empty PHP DDEV project 48 | run: ddev config --project-type=php --project-name=${{env.DDEV_PROJECT}} --php-version=${{ matrix.php-version }} 49 | 50 | - name: Add Redis, Memcached and Crowdsec 51 | run: | 52 | ddev get ddev/ddev-redis 53 | ddev get ddev/ddev-memcached 54 | # override redis.conf 55 | ddev get julienloizelet/ddev-tools 56 | ddev get julienloizelet/ddev-crowdsec-php 57 | 58 | - name: Start DDEV 59 | run: ddev start 60 | 61 | - name: Set BOUNCER_KEY and PROXY_IP env 62 | run: | 63 | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV 64 | echo "PROXY_IP=$(ddev find-ip ddev-router)" >> $GITHUB_ENV 65 | 66 | - name: Some DEBUG information 67 | run: | 68 | ddev --version 69 | ddev exec php -v 70 | ddev exec -s crowdsec crowdsec -version 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/crowdsec-bouncer-lib 77 | 78 | - name: Validate composer.json 79 | run: ddev composer validate --strict --working-dir ./${{env.EXTENSION_PATH}} 80 | 81 | - name: Install CrowdSec lib dependencies 82 | run: | 83 | ddev composer update --working-dir ./${{env.EXTENSION_PATH}} 84 | 85 | - name: Set excluded groups 86 | id: set-excluded-groups 87 | if: contains(fromJson('["7.2","7.3"]'),matrix.php-version) 88 | run: echo "exclude_group=$(echo --exclude-group up-to-php74 )" >> $GITHUB_OUTPUT 89 | 90 | - name: Run "Unit Tests" 91 | run: | 92 | ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox ${{ steps.set-excluded-groups.outputs.exclude_group }} ./${{env.EXTENSION_PATH}}/tests/Unit 93 | 94 | - name: Prepare PHP Integration tests 95 | run: | 96 | mkdir ${{ github.workspace }}/cfssl 97 | cp -r .ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* ${{ github.workspace }}/cfssl 98 | ddev maxmind-download DEFAULT GeoLite2-City /var/www/html/${{env.EXTENSION_PATH}}/tests 99 | ddev maxmind-download DEFAULT GeoLite2-Country /var/www/html/${{env.EXTENSION_PATH}}/tests 100 | cd ${{env.EXTENSION_PATH}}/tests 101 | sha256sum -c GeoLite2-Country.tar.gz.sha256.txt 102 | sha256sum -c GeoLite2-City.tar.gz.sha256.txt 103 | tar -xf GeoLite2-Country.tar.gz 104 | tar -xf GeoLite2-City.tar.gz 105 | rm GeoLite2-Country.tar.gz GeoLite2-Country.tar.gz.sha256.txt GeoLite2-City.tar.gz GeoLite2-City.tar.gz.sha256.txt 106 | 107 | - name: Run "IP verification" test 108 | run: | 109 | ddev exec BOUNCER_KEY=${{ env.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 ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/AbstractBouncerTest.php 110 | 111 | - name: Run "IP verification with TLS" test 112 | run: | 113 | ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} 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 ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/AbstractBouncerTest.php 114 | 115 | - name: Run "Geolocation with cURL" test 116 | run: | 117 | ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Integration/GeolocationTest.php 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | vendor 3 | composer.lock 4 | composer-dev.* 5 | 6 | # Systems 7 | .DS_Store 8 | 9 | #Tools 10 | super-linter.log 11 | .php-cs-fixer.cache 12 | .php-cs-fixer.php 13 | tools/php-cs-fixer/composer.lock 14 | 15 | # App 16 | var/ 17 | .cache 18 | .logs 19 | 20 | # Auto prepend demo 21 | scripts/auto-prepend/settings.php 22 | scripts/auto-prepend/.logs 23 | scripts/auto-prepend/.cache 24 | scripts/**/*.log 25 | 26 | # MaxMind databases 27 | *.mmdb 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | 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). 5 | 6 | ## SemVer public API 7 | 8 | The [public API](https://semver.org/spec/v2.0.0.html#spec-item-1) of this library consists of all public or protected methods, properties and constants belonging to the `src` folder. 9 | 10 | As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com/doc/current/contributing/code/bc.html#working-on-symfony-code) when deciding whether a change is a breaking change or not. 11 | 12 | --- 13 | 14 | ## [4.3.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v4.3.0) - 2025-04-30 15 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v4.2.0...v4.3.0) 16 | 17 | 18 | ### Added 19 | 20 | - Add `hasBaasUri` method to detect if the bouncer is connected to a Block As A Service Lapi 21 | - Add `resetUsageMetrics` method to reset the usage metrics cache item 22 | 23 | --- 24 | 25 | ## [4.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v4.2.0) - 2025-01-31 26 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v4.1.0...v4.2.0) 27 | 28 | 29 | ### Changed 30 | 31 | - Allow Monolog 3 package and Symfony 7 packages 32 | 33 | --- 34 | 35 | 36 | ## [4.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v4.1.0) - 2025-01-10 37 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v4.0.0...v4.1.0) 38 | 39 | 40 | ### Changed 41 | 42 | - Do not save origins count when the bouncer does not bounce the IP, due to business logic. This avoids sending a 43 | "processed" usage metrics to the LAPI when the IP is not bounced at all. 44 | 45 | --- 46 | 47 | ## [4.0.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v4.0.0) - 2025-01-09 48 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.2.0...v4.0.0) 49 | 50 | 51 | ### Added 52 | 53 | - Add `pushUsageMetrics` method to `AbstractBouncer` class 54 | - Save origins count item in cache after a remediation has been applied 55 | 56 | ### Changed 57 | 58 | - **Breaking change**: `AbstractBouncer::getRemediationForIp` method returns now an array with `remediation` and 59 | `origin` keys. 60 | - **Breaking change**: `$remediationEngine` params of `AbstractBouncer` constructor is now a `LapiRemediationEngine` instance 61 | - **Breaking change**: `AbstractBouncer::getAppSecRemediationForIp` don't need `$remediationEngine` param anymore 62 | - **Breaking change**: `AbstractBouncer::handleRemediation` requires a new `origin` param 63 | - Update `crowdsec/remediation-engine` dependency to `v4.0.0` 64 | 65 | ### Removed 66 | 67 | - **Breaking change**: Remove `bouncing_level` constants and configuration as it is now in `crowdsec/remediation-engine` package 68 | 69 | 70 | --- 71 | 72 | ## [3.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.2.0) - 2024-10-23 73 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.1.0...v3.2.0) 74 | 75 | 76 | ### Added 77 | 78 | - Add protected `buildRequestRawBody` helper method to `AbstractBouncer` class 79 | 80 | --- 81 | 82 | ## [3.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.1.0) - 2024-10-18 83 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.0.0...v3.1.0) 84 | 85 | 86 | ### Changed 87 | 88 | - Update `crowdsec/remediation-engine` dependency to `v3.5.0` (`appsec_max_body_size_kb` and 89 | `appsec_body_size_exceeded_action` settings) 90 | 91 | 92 | --- 93 | 94 | ## [3.0.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.0.0) - 2024-10-04 95 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v2.2.0...v3.0.0) 96 | 97 | 98 | ### Added 99 | 100 | - Add AppSec support 101 | - Add `use_appsec` configuration 102 | 103 | ### Changed 104 | 105 | - *Breaking change*: Add abstract methods that must be implemented to use AppSec: 106 | - `getRequestHost` 107 | - `getRequestHeaders` 108 | - `getRequestRawBody` 109 | - `getRequestUserAgent` 110 | - `bounceCurrentIp` method asks for AppSec remediation if `use_appsec` is true and IP remediation is `bypass` 111 | - Update `crowdsec/common` dependency to `v2.3.0` 112 | - Update `crowdsec/remediation-engine` dependency to `v3.4.0` 113 | 114 | ### Removed 115 | 116 | - *Breaking change*: Remove `DEFAULT_LAPI_URL` constant as it already exists in `crowdsec/lapi-client` package 117 | 118 | 119 | --- 120 | 121 | ## [2.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v2.2.0) - 2024-06-20 122 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v2.1.0...v2.2.0) 123 | 124 | 125 | ### Changed 126 | 127 | - Change the visibility of `AbstractBouncer::getBanHtml` and `AbstractBouncer::getCaptchaHtml` to `protected` to enable custom html rendering implementation 128 | 129 | 130 | --- 131 | 132 | 133 | ## [2.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v2.1.0) - 2023-12-14 134 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v2.0.0...v2.1.0) 135 | 136 | 137 | ### Changed 138 | 139 | - Update `gregwar/captcha` from `1.2.0` to `1.2.1` and remove override fixes 140 | - Update `crowdsec/common` dependency to `v2.2.0` (`api_connect_timeout` setting) 141 | - Update `crowdsec/remediation-engine` dependency to `v3.3.0` (`api_connect_timeout` setting) 142 | 143 | 144 | --- 145 | 146 | ## [2.0.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v2.0.0) - 2023-04-13 147 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.4.0...v2.0.0) 148 | 149 | 150 | ### Changed 151 | 152 | - Update `gregwar/captcha` from `1.1.9` to `1.2.0` and remove some override fixes 153 | 154 | ### Removed 155 | 156 | - Remove all code about standalone bouncer 157 | 158 | --- 159 | 160 | 161 | ## [1.4.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.4.0) - 2023-03-30 162 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.3.0...v1.4.0) 163 | 164 | 165 | ### Changed 166 | - Do not rotate log files of standalone bouncer 167 | 168 | --- 169 | 170 | 171 | ## [1.3.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.3.0) - 2023-03-24 172 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.2.0...v1.3.0) 173 | 174 | 175 | ### Changed 176 | - Use `crowdsec/remediation-engine` `^3.1.1` instead of `^3.0.0` 177 | - Use Redis and PhpFiles cache without cache tags 178 | 179 | --- 180 | 181 | 182 | ## [1.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.2.0) - 2023-03-09 183 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.1.1...v1.2.0) 184 | 185 | 186 | ### Changed 187 | - Use `crowdsec/remediation-engine` `^3.0.0` instead of `^2.0.0` 188 | 189 | ### Added 190 | - Add a script to prune cache with a cron job (Standalone bouncer) 191 | 192 | --- 193 | 194 | 195 | ## [1.1.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.1.1) - 2023-02-16 196 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.1.0...v1.1.1) 197 | 198 | ### Fixed 199 | - Fix log messages for captcha remediation 200 | 201 | --- 202 | 203 | ## [1.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.1.0) - 2023-02-16 204 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.0.1...v1.1.0) 205 | 206 | ### Changed 207 | - Add more log messages during bouncing process 208 | 209 | --- 210 | 211 | ## [1.0.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.0.1) - 2023-02-10 212 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v1.0.0...v1.0.1) 213 | 214 | ### Fixed 215 | - Update `AbstractBouncer::testCacheConnection` method to throw an exception for Memcached if necessary 216 | 217 | 218 | --- 219 | 220 | ## [1.0.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v1.0.0) - 2023-02-03 221 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.36.0...v1.0.0) 222 | 223 | ### Changed 224 | - Change version to `1.0.0`: first stable release 225 | - Update `crowdsec/remediation-engine` to a new major version [2.0.0](https://github.com/crowdsecurity/php-remediation-engine/releases/tag/v2.0.0) 226 | - Use `crowdsec/common` [package](https://github.com/crowdsecurity/php-common) as a dependency for code factoring 227 | 228 | ### Added 229 | 230 | - Add public API declaration 231 | 232 | 233 | --- 234 | 235 | 236 | ## [0.36.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.36.0) - 2023-01-26 237 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.35.0...v0.36.0) 238 | 239 | ### Changed 240 | - *Breaking changes*: All the code has been refactored to use `crowdsec/remediation-engine` package: 241 | - Lot of public methods have been deleted or replaced by others 242 | - A bouncer should now extend an `AbstractBouncer` class and implements some abstract methods 243 | - Some settings names have been changed 244 | 245 | 246 | --- 247 | 248 | 249 | ## [0.35.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.35.0) - 2022-12-16 250 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.34.0...v0.35.0) 251 | 252 | ### Changed 253 | - Set default timeout to 120 and allow negative value for unlimited timeout 254 | 255 | --- 256 | 257 | 258 | ## [0.34.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.34.0) - 2022-11-24 259 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.33.0...v0.34.0) 260 | 261 | ### Changed 262 | - Do not cache bypass decision in stream mode 263 | - Replace unauthorized chars by underscore `_` in cache key 264 | 265 | ### Added 266 | - Add compatibility with PHP 8.2 267 | 268 | ### Fixed 269 | - Fix decision duration parsing when it uses milliseconds 270 | 271 | --- 272 | 273 | 274 | ## [0.33.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.33.0) - 2022-11-10 275 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.32.0...v0.33.0) 276 | 277 | ### Changed 278 | - Do not use tags for `memcached` as it is discouraged 279 | 280 | ### Fixed 281 | - In stream mode, a clean IP decision (`bypass`) was not cached at all. The decision is now cached for ten years as expected 282 | 283 | --- 284 | 285 | ## [0.32.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.32.0) - 2022-09-29 286 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.31.0...v0.32.0) 287 | 288 | ### Changed 289 | - Refactor for coding standards (PHPMD, PHPCS) 290 | 291 | --- 292 | 293 | ## [0.31.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.31.0) - 2022-09-23 294 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.30.0...v0.31.0) 295 | 296 | ### Changed 297 | - Use Twig as template engine for ban and captcha walls 298 | 299 | --- 300 | 301 | ## [0.30.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.30.0) - 2022-09-22 302 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.29.0...v0.30.0) 303 | 304 | ### Changed 305 | - Update `symfony/cache` and `symfony/config` dependencies requirement 306 | 307 | --- 308 | ## [0.29.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.29.0) - 2022-08-11 309 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.28.0...v0.29.0) 310 | 311 | ### Added 312 | - Add TLS authentication feature 313 | 314 | --- 315 | ## [0.28.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.28.0) - 2022-08-04 316 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.27.0...v0.28.0) 317 | ### Changed 318 | - *Breaking change*: Rename `ClientAbstract` class to `AbstractClient` 319 | - Hide `api_key` in log 320 | 321 | ### Added 322 | - Add `disable_prod_log` configuration 323 | 324 | --- 325 | ## [0.27.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.27.0) - 2022-07-29 326 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.26.0...v0.27.0) 327 | ### Changed 328 | - *Breaking change*: Modify `getBouncerInstance` and `init` signatures 329 | 330 | ### Fixed 331 | - Fix wrongly formatted range scoped decision retrieving 332 | - Fix cache updated decisions count 333 | --- 334 | ## [0.26.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.26.0) - 2022-07-28 335 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.25.0...v0.26.0) 336 | ### Changed 337 | - *Breaking change*: Modify all constructors (`Bouncer`, `ApiCache`, `ApiClient`, `RestClient`) to use only 338 | configurations and logger as parameters 339 | - Use `shouldBounceCurrentIp` method of Standalone before bouncer instantiation 340 | - *Breaking change*: Modify `initLogger` method 341 | --- 342 | ## [0.25.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.25.0) - 2022-07-22 343 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.24.0...v0.25.0) 344 | ### Added 345 | - Add a `use_curl` setting to make LAPI rest requests with `cURL` instead of `file_get_contents` 346 | --- 347 | ## [0.24.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.24.0) - 2022-07-08 348 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.23.0...v0.24.0) 349 | ### Added 350 | - Add a `configs` attribute to Bouncer class 351 | --- 352 | ## [0.23.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.23.0) - 2022-07-07 353 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.22.1...v0.23.0) 354 | ### Added 355 | - Add test configuration to mock IPs and proxy behavior 356 | --- 357 | ## [0.22.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.22.1) - 2022-06-03 358 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.22.0...v0.22.1) 359 | ### Fixed 360 | - Handle custom error handler for Memcached tag aware adapter 361 | --- 362 | ## [0.22.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.22.0) - 2022-06-02 363 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.21.0...v0.22.0) 364 | ### Added 365 | - Add configurations for captcha and geolocation variables cache duration 366 | ### Changed 367 | - *Breaking change*: Use cache instead of session to store captcha and geolocation variables 368 | - *Breaking change*: Use symfony cache tag adapter 369 | - Change `geolocation/save_in_session` setting into `geolocation/save_result` 370 | ### Fixed 371 | - Fix deleted decision count during cache update 372 | --- 373 | ## [0.21.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.21.0) - 2022-04-15 374 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.20.1...v0.21.0) 375 | ### Changed 376 | - Change allowed versions of `symfony/cache` package 377 | --- 378 | ## [0.20.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.20.1) - 2022-04-07 379 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.20.0...v0.20.1) 380 | ### Added 381 | - Handle old lib version (`< 0.14.0`) settings values retro-compatibility for Standalone bouncer 382 | ### Fixed 383 | - Fix `AbstractBounce:displayCaptchaWall` function 384 | 385 | --- 386 | ## [0.20.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.20.0) - 2022-03-31 387 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.19.0...v0.20.0) 388 | ### Changed 389 | - Require a minimum of 1 for `clean_ip_cache_duration` and `bad_ip_cache_duration` settings 390 | - Do not use session for geolocation if `save_in_session` setting is not true. 391 | --- 392 | ## [0.19.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.19.0) - 2022-03-24 393 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.18.0...v0.19.0) 394 | ### Added 395 | - Add `excluded_uris` configuration to exclude some uris (was hardcoded to `/favicon.ico`) 396 | 397 | ### Changed 398 | - Change the redirection after captcha resolution to `/` (was `$_SERVER['REQUEST_URI']'`) 399 | 400 | ### Fixed 401 | - Fix Standalone bouncer session handling 402 | 403 | --- 404 | ## [0.18.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.18.0) - 2022-03-18 405 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.17.1...v0.18.0) 406 | ### Changed 407 | - *Breaking change*: Change `trust_ip_forward_array` symfony configuration node to an array of array. 408 | --- 409 | ## [0.17.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.17.1) - 2022-03-17 410 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.17.0...v0.17.1) 411 | ### Removed 412 | - Remove testing scripts for quality gate test 413 | 414 | --- 415 | ## [0.17.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.17.0) - 2022-03-17 416 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.16.0...v0.17.0) 417 | ### Changed 418 | - *Breaking change*: Refactor some logic of important methods (`init`, `run`, `safelyBounce`, `getBouncerInstance`) 419 | - *Breaking change*: Change the configurations' verification by using `symfony/config` logic whenever it is possible 420 | - *Breaking change*: Change scripts path, name and content (specifically auto-prepend-file' scripts and settings) 421 | - *Breaking change*: Change `IBounce` interface 422 | - *Breaking change*: Rename `StandAloneBounce` class by `StandaloneBounce` 423 | - Rewrite documentations 424 | 425 | ### Fixed 426 | - Fix `api_timeout` configuration 427 | 428 | ### Removed 429 | - Remove all unmaintained test and development docker files, sh scripts and associated documentation 430 | - Remove `StandaloneBounce::isConfigValid` method as all is already checked 431 | 432 | --- 433 | ## [0.16.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.16.0) - 2022-03-10 434 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.15.0...v0.16.0) 435 | ### Added 436 | - Add geolocation feature to get remediation from `Country` scoped decisions (using MaxMind databases) 437 | - Add end-to-end tests GitHub action 438 | - Add GitHub action to check links in markdown and update TOC 439 | 440 | ### Changed 441 | - *Breaking change*: Remove `live_mode` occurrences and use `stream_mode` instead 442 | - Change PHP scripts for testing examples (auto-prepend, cron) 443 | - Update docs 444 | 445 | ### Fixed 446 | - Fix debug log in `no-dev` environment 447 | - Fix empty logs in Unit Tests 448 | --- 449 | ## [0.15.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.15.0) - 2022-02-24 450 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.14.0...v0.15.0) 451 | ### Added 452 | - Add tests for PHP 8.1 (memcached is excluded) 453 | - Add GitHub action for Release process 454 | - Add `CHANGELOG.md` 455 | ### Changed 456 | - Use `BouncerException` for some specific errors 457 | ### Fixed 458 | - Fix auto-prepend script: set `debug_mode` and `display_errors` values before bouncer init 459 | - Fix `gregwar/captcha` for PHP 8.1 460 | - Fix BouncerException arguments in `set_error_handler` method 461 | 462 | ### Removed 463 | - Remove `composer.lock` file 464 | 465 | --- 466 | ## [0.14.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.14.0) - 2021-11-18 467 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.13.3...v0.14.0) 468 | ### Changed 469 | - *Breaking change*: Fix typo in constant name (`boucing`=> `bouncing`) 470 | - Allow older versions of symfony config and monolog 471 | - Split debug logic in 2 : debug and display 472 | - Redirect if captcha is resolved 473 | - Update doc and scripts 474 | --- 475 | ## [0.13.3](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.13.3) - 2021-09-21 476 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.13.2...v0.13.3) 477 | ### Fixed 478 | - Fix session handling with standalone library 479 | --- 480 | ## [0.13.2](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.13.2) - 2021-08-24 481 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.13.1...v0.13.2) 482 | ### Added 483 | - Handle invalid ip format 484 | --- 485 | ## [0.13.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.13.1) - 2021-07-01 486 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.13.0...v0.13.1) 487 | ### Changed 488 | - Close php session after bouncing 489 | --- 490 | ## [0.13.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.13.0) - 2021-06-24 491 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.12.0...v0.13.0) 492 | ### Fixed 493 | - Fix standalone mode 494 | --- 495 | ## [0.12.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.12.0) - 2021-06-24 496 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.11.0...v0.12.0) 497 | ### Added 498 | - Add standalone mode 499 | --- 500 | ## [0.11.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.11.0) - 2021-06-24 501 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.10.0...v0.11.0) 502 | ### Added 503 | - Add a `Bounce` class to simplify specific implementations 504 | - Add a `Standalone` implementation of the `Bounce` class 505 | --- 506 | ## [0.10.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.10.0) - 2021-01-23 507 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.9.0...v0.10.0) 508 | ### Added 509 | - Add Ipv6 support 510 | --- 511 | ## [0.9.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.9.0) - 2021-01-13 512 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.6...v0.9.0) 513 | ### Added 514 | - Add custom remediation templates 515 | 516 | --- 517 | ## [0.8.6](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.6) - 2021-01-05 518 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.5...v0.8.6) 519 | ### Fixed 520 | - Fix version bump 521 | 522 | --- 523 | ## [0.8.5](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.5) - 2021-01-05 524 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.4...v0.8.5) 525 | ### Fixed 526 | - Fix memcached edge case with long duration cache (unwanted int to float conversion) 527 | --- 528 | ## [0.8.4](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.4) - 2020-12-26 529 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.3...v0.8.4) 530 | ### Fixed 531 | - Fix fallback remediation 532 | 533 | --- 534 | ## [0.8.3](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.3) - 2020-12-24 535 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.2...v0.8.3) 536 | ### Changed 537 | - Do not set expiration limits in stream mode 538 | --- 539 | ## [0.8.2](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.2) - 2020-12-23 540 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.1...v0.8.2) 541 | ### Fixed 542 | - Fix release process 543 | --- 544 | ## [0.8.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.1) - 2020-12-22 545 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.8.0...v0.8.1) 546 | ### Fixed 547 | - Fix release process 548 | --- 549 | ## [0.8.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.8.0) - 2020-12-22 550 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.7.0...v0.8.0) 551 | ### Added 552 | - Add redis+memcached test connection 553 | --- 554 | ## [0.7.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.7.0) - 2020-12-22 555 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.6.0...v0.7.0) 556 | ### Added 557 | - Make crowdsec mentions hidable 558 | - Add phpcs 559 | ### Changed 560 | - Update doc 561 | - Make a lint pass 562 | ### Fixed 563 | - Fix fallback remediation 564 | --- 565 | ## [0.6.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.6.0) - 2020-12-20 566 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.5.2...v0.6.0) 567 | ### Changed 568 | - Remove useless dockerfiles 569 | --- 570 | ## [0.5.2](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.5.2) - 2020-12-19 571 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.5.1...v0.5.2) 572 | ### Changed 573 | - Update docs 574 | --- 575 | ## [0.5.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.5.1) - 2020-12-19 576 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.5.0...v0.5.1) 577 | ### Changed 578 | - Make a lint pass 579 | --- 580 | ## [0.5.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.5.0) - 2020-12-19 581 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.4.4...v0.5.0) 582 | ### Added 583 | - Add cache expiration for bad ips 584 | - Include the GregWar Captcha generation lib 585 | - Build nice 403 and captcha templates 586 | - Log captcha resolutions 587 | ### Changed 588 | - Use the latest CrowdSec docker image 589 | - Use the "context" psr log feature for all logs to allow them to be parsable. 590 | - Remove useless predis dependence 591 | --- 592 | ## [0.4.4](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.4.4) - 2020-12-15 593 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.4.3...v0.4.4) 594 | ### Changed 595 | - Improve logging 596 | --- 597 | ## [0.4.3](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.4.3) - 2020-12-13 598 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.4.2...v0.4.3) 599 | 600 | ### Changed 601 | - Improve logging 602 | --- 603 | ## [0.4.2](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.4.2) - 2020-12-12 604 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.4.1...v0.4.2) 605 | 606 | ### Fixed 607 | - Fix durations bug 608 | --- 609 | ## [0.4.1](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.4.1) - 2020-12-12 610 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.4.0...v0.4.1) 611 | 612 | ### Added 613 | - Use GitHub flow 614 | --- 615 | ## [0.4.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.4.0) - 2020-12-12 616 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.3.0...v0.4.0) 617 | 618 | ### Added 619 | - Add release drafter 620 | - Reduce cache durations 621 | - Add remediation fallback 622 | --- 623 | ## [0.3.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.3.0) - 2020-12-09 624 | [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v0.2.0...v0.3.0) 625 | 626 | ### Added 627 | - Set PHP Files cache adapter as default 628 | - Replace phpdoc template with phpdocmd 629 | - Improve documentation add examples and a complete guide. 630 | - Auto warmup cache 631 | --- 632 | ## [0.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v0.2.0) - 2020-12-08 633 | ### Added 634 | - Initial release 635 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 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/php-cs-bouncer/main/docs/images/logo_crowdsec.png) 2 | 3 | # CrowdSec Bouncer PHP library 4 | 5 | > The official PHP bouncer library for the CrowdSec LAPI 6 | 7 | ![Version](https://img.shields.io/github/v/release/crowdsecurity/php-cs-bouncer?include_prereleases) 8 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=crowdsecurity_php-cs-bouncer&metric=alert_status)](https://sonarcloud.io/dashboard?id=crowdsecurity_php-cs-bouncer) 9 | [![Test suite](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/test-suite.yml/badge.svg)](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/test-suite.yml) 10 | [![Coding standards](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/coding-standards.yml) 11 | ![Licence](https://img.shields.io/github/license/crowdsecurity/php-cs-bouncer) 12 | 13 | 14 | :books: Documentation 15 | :diamond_shape_with_a_dot_inside: Hub 16 | :speech_balloon: Discourse Forum 17 | 18 | 19 | ## Overview 20 | 21 | This library allows you to create CrowdSec bouncers for PHP applications or frameworks like e-commerce, blog or other exposed applications. 22 | 23 | ## Usage 24 | 25 | See [User Guide](https://github.com/crowdsecurity/php-cs-bouncer/blob/main/docs/USER_GUIDE.md) 26 | 27 | ## Installation 28 | 29 | See [Installation Guide](https://github.com/crowdsecurity/php-cs-bouncer/blob/main/docs/INSTALLATION_GUIDE.md) 30 | 31 | 32 | ## Technical notes 33 | 34 | See [Technical notes](https://github.com/crowdsecurity/php-cs-bouncer/blob/main/docs/TECHNICAL_NOTES.md) 35 | 36 | ## Developer guide 37 | 38 | See [Developer guide](https://github.com/crowdsecurity/php-cs-bouncer/blob/main/docs/DEVELOPER.md) 39 | 40 | 41 | ## License 42 | 43 | [MIT](https://github.com/crowdsecurity/php-cs-bouncer/blob/main/LICENSE) 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crowdsec/bouncer", 3 | "description": "The official PHP bouncer library for the CrowdSec Local API", 4 | "type": "library", 5 | "license": "MIT", 6 | "minimum-stability": "stable", 7 | "keywords": [ 8 | "security", 9 | "crowdsec", 10 | "waf", 11 | "middleware", 12 | "http", 13 | "blocker", 14 | "bouncer", 15 | "captcha", 16 | "geoip", 17 | "ip", 18 | "ip range", 19 | "appsec" 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "CrowdSecBouncer\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "CrowdSecBouncer\\Tests\\": "tests/" 29 | } 30 | }, 31 | "authors": [ 32 | { 33 | "name": "Lucas Cherifi", 34 | "email": "lucas@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/remediation-engine": "^4.2.0", 45 | "crowdsec/lapi-client": "^3.6.0", 46 | "crowdsec/common": "^3.0.0", 47 | "symfony/config": "^4.4.27 || ^5.2 || ^6.0 || ^7.2", 48 | "monolog/monolog": "^1.17 || ^2.1 || ^3.8", 49 | "psr/log": "^1.0 || ^2.0 || ^3.0", 50 | "twig/twig": "^3.4.2", 51 | "gregwar/captcha": "^1.2.1", 52 | "mlocati/ip-lib": "^1.18", 53 | "ext-json": "*", 54 | "ext-gd": "*" 55 | }, 56 | "suggest": { 57 | "ext-curl": "*" 58 | }, 59 | "require-dev": { 60 | "phpunit/phpunit": "^8.5.30 || ^9.3", 61 | "mikey179/vfsstream": "^1.6.11", 62 | "nikic/php-parser": "^4.18", 63 | "ext-curl": "*" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/DEVELOPER.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | # CrowdSec Bouncer PHP library 3 | 4 | ## Developer guide 5 | 6 | 7 | 8 | 9 | **Table of Contents** 10 | 11 | - [Local development](#local-development) 12 | - [DDEV setup](#ddev-setup) 13 | - [DDEV installation](#ddev-installation) 14 | - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) 15 | - [DDEV Usage](#ddev-usage) 16 | - [Add CrowdSec bouncer and watcher](#add-crowdsec-bouncer-and-watcher) 17 | - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) 18 | - [Find IP of your docker services](#find-ip-of-your-docker-services) 19 | - [Unit test](#unit-test) 20 | - [Integration test](#integration-test) 21 | - [Coding standards](#coding-standards) 22 | - [Generate CrowdSec tools and settings on start](#generate-crowdsec-tools-and-settings-on-start) 23 | - [Redis debug](#redis-debug) 24 | - [Memcached debug](#memcached-debug) 25 | - [Discover the CrowdSec LAPI](#discover-the-crowdsec-lapi) 26 | - [Use the CrowdSec cli (`cscli`)](#use-the-crowdsec-cli-cscli) 27 | - [Add decision for an IP or a range of IPs](#add-decision-for-an-ip-or-a-range-of-ips) 28 | - [Add decision to ban or captcha a country](#add-decision-to-ban-or-captcha-a-country) 29 | - [Delete decisions](#delete-decisions) 30 | - [Create a bouncer](#create-a-bouncer) 31 | - [Create a watcher](#create-a-watcher) 32 | - [Use the web container to call LAPI](#use-the-web-container-to-call-lapi) 33 | - [Commit message](#commit-message) 34 | - [Allowed message `type` values](#allowed-message-type-values) 35 | - [Update documentation table of contents](#update-documentation-table-of-contents) 36 | - [Release process](#release-process) 37 | 38 | 39 | 40 | 41 | 42 | ## Local development 43 | 44 | There are many ways to install this library on a local PHP environment. 45 | 46 | We are using [DDEV](https://ddev.readthedocs.io/en/stable/) because it is quite simple to use and customize. 47 | 48 | Of course, you may use your own local stack, but we provide here some useful tools that depends on DDEV. 49 | 50 | 51 | ### DDEV setup 52 | 53 | For a quick start, follow the below steps. 54 | 55 | 56 | #### DDEV installation 57 | 58 | For the DDEV installation, please follow the [official instructions](https://ddev.readthedocs.io/en/stable/users/install/ddev-installation/). 59 | 60 | 61 | #### Prepare DDEV PHP environment 62 | 63 | The final structure of the project will look like below. 64 | 65 | ``` 66 | crowdsec-bouncer-project (choose the name you want for this folder) 67 | │ 68 | │ (your php project sources; could be a simple index.php file) 69 | │ 70 | └───.ddev 71 | │ │ 72 | │ │ (DDEV files) 73 | │ 74 | └───my-code (do not change this folder name) 75 | │ 76 | │ 77 | └───crowdsec-bouncer-lib (do not change this folder name) 78 | │ 79 | │ (Clone of this repo) 80 | 81 | ``` 82 | 83 | - Create an empty folder that will contain all necessary sources: 84 | ```bash 85 | mkdir crowdsec-bouncer-project 86 | ``` 87 | 88 | - Create a DDEV php project: 89 | 90 | ```bash 91 | cd crowdsec-bouncer-project 92 | ddev config --project-type=php --php-version=8.2 --project-name=crowdsec-bouncer-lib 93 | ``` 94 | 95 | - Add some DDEV add-ons: 96 | 97 | ```bash 98 | ddev get ddev/ddev-redis 99 | ddev get ddev/ddev-memcached 100 | ddev get julienloizelet/ddev-tools 101 | ddev get julienloizelet/ddev-crowdsec-php 102 | ``` 103 | 104 | - Clone this repo sources in a `my-code/crowdsec-bouncer-lib` folder: 105 | 106 | ```bash 107 | mkdir -p my-code/crowdsec-bouncer-lib 108 | cd my-code/crowdsec-bouncer-lib && git clone git@github.com:crowdsecurity/php-cs-bouncer.git ./ 109 | ``` 110 | 111 | - Launch DDEV 112 | 113 | ```bash 114 | cd .ddev && ddev start 115 | ``` 116 | This should take some times on the first launch as this will download all necessary docker images. 117 | 118 | 119 | ### DDEV Usage 120 | 121 | 122 | #### Add CrowdSec bouncer and watcher 123 | 124 | - To create a new bouncer in the CrowdSec container, run: 125 | 126 | ```bash 127 | ddev create-bouncer [name] 128 | ``` 129 | 130 | It will return the bouncer key. 131 | 132 | - To create a new watcher, run: 133 | 134 | ```bash 135 | ddev create-watcher [name] [password] 136 | ``` 137 | 138 | **N.B.** : Since we are using TLS authentication for agent, you should avoid to create a watcher with this method. 139 | 140 | 141 | #### Use composer to update or install the lib 142 | 143 | Run: 144 | 145 | ```bash 146 | ddev composer update --working-dir ./my-code/crowdsec-bouncer-lib 147 | ``` 148 | 149 | For advanced usage, you can create a `composer-dev.json` file in the `my-code/crowdsec-bouncer-lib` folder and run: 150 | 151 | ```bash 152 | ddev exec COMPOSER=composer-dev.json composer update --working-dir ./my-code/crowdsec-bouncer-lib 153 | ``` 154 | 155 | 156 | #### Find IP of your docker services 157 | 158 | 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. 159 | 160 | To find it, just run: 161 | 162 | ```bash 163 | ddev find-ip 164 | ``` 165 | 166 | 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. 167 | 168 | To find this IP, just run: 169 | 170 | ```bash 171 | ddev find-ip ddev-router 172 | ``` 173 | 174 | 175 | #### Unit test 176 | 177 | 178 | ```bash 179 | ddev php ./my-code/crowdsec-bouncer-lib/vendor/bin/phpunit ./my-code/crowdsec-bouncer-lib/tests/Unit --testdox 180 | ``` 181 | 182 | #### Integration test 183 | 184 | First, create a bouncer and keep the result key. 185 | 186 | ```bash 187 | ddev create-bouncer 188 | ``` 189 | 190 | Then, as we use a TLS ready CrowdSec container, you have to copy some certificates and key: 191 | 192 | ```bash 193 | cd crowdsec-bouncer-project 194 | mkdir cfssl 195 | cp -r ../.ddev/okaeli-add-on/custom_files/crowdsec/cfssl/* cfssl 196 | ``` 197 | 198 | Finally, run 199 | 200 | 201 | ```bash 202 | 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/crowdsec-bouncer-lib/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/crowdsec-bouncer-lib/tests/Integration/AbstractBouncerTest.php 203 | ``` 204 | 205 | For geolocation Unit Test, you should first put 2 free MaxMind databases in the `tests` folder : `GeoLite2-City.mmdb` 206 | and `GeoLite2-Country.mmdb`. You can download these databases by creating a MaxMind account and browse to [the download page](https://www.maxmind.com/en/accounts/current/geoip/downloads). 207 | 208 | 209 | Then, you can run: 210 | 211 | ```bash 212 | 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/crowdsec-bouncer-lib/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/crowdsec-bouncer-lib/tests/Integration/GeolocationTest.php 213 | ``` 214 | 215 | 216 | **N.B**.: If you want to test with `tls` authentification, you have to add `BOUNCER_TLS_PATH` environment variable 217 | and specify the path where you store certificates and keys. For example: 218 | 219 | ```bash 220 | 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/crowdsec-bouncer-lib/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./my-code/crowdsec-bouncer-lib/tests/Integration/AbstractBouncerTest.php 221 | ``` 222 | 223 | 224 | #### Coding standards 225 | 226 | 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: 227 | 228 | ```bash 229 | ddev composer update --working-dir=./my-code/crowdsec-bouncer-lib/tools/coding-standards 230 | ``` 231 | 232 | For advanced usage, you can create a `composer-dev.json` file in the 233 | `my-code/crowdsec-bouncer-lib/tools/coding-standards` folder and 234 | run: 235 | 236 | ```bash 237 | ddev exec COMPOSER=composer-dev.json composer update --working-dir ./my-code/crowdsec-bouncer-lib/tools/coding-standards 238 | ``` 239 | 240 | Then, you can use the following commands: 241 | 242 | 243 | ##### PHPCS Fixer 244 | 245 | We are using the [PHP Coding Standards Fixer](https://cs.symfony.com/). With ddev, you can do the following: 246 | 247 | 248 | ```bash 249 | ddev phpcsfixer my-code/crowdsec-bouncer-lib/tools/coding-standards/php-cs-fixer ../ 250 | 251 | ``` 252 | 253 | ##### PHPSTAN 254 | 255 | To use the [PHPSTAN](https://github.com/phpstan/phpstan) tool, you can run: 256 | 257 | 258 | ```bash 259 | ddev phpstan /var/www/html/my-code/crowdsec-bouncer-lib/tools/coding-standards phpstan/phpstan.neon /var/www/html/my-code/crowdsec-bouncer-lib/src 260 | 261 | ``` 262 | 263 | 264 | ##### PHP Mess Detector 265 | 266 | To use the [PHPMD](https://github.com/phpmd/phpmd) tool, you can run: 267 | 268 | ```bash 269 | ddev phpmd ./my-code/crowdsec-bouncer-lib/tools/coding-standards phpmd/rulesets.xml ../../src 270 | 271 | ``` 272 | 273 | ##### PHPCS and PHPCBF 274 | 275 | To use [PHP Code Sniffer](https://github.com/squizlabs/PHP_CodeSniffer) tools, you can run: 276 | 277 | ```bash 278 | ddev phpcs ./my-code/crowdsec-bouncer-lib/tools/coding-standards my-code/crowdsec-bouncer-lib/src PSR12 279 | ``` 280 | 281 | and: 282 | 283 | ```bash 284 | ddev phpcbf ./my-code/crowdsec-bouncer-lib/tools/coding-standards my-code/crowdsec-bouncer-lib/src PSR12 285 | ``` 286 | 287 | 288 | ##### PSALM 289 | 290 | To use [PSALM](https://github.com/vimeo/psalm) tools, you can run: 291 | 292 | ```bash 293 | ddev psalm ./my-code/crowdsec-bouncer-lib/tools/coding-standards ./my-code/crowdsec-bouncer-lib/tools/coding-standards/psalm 294 | ``` 295 | 296 | ##### PHP Unit Code coverage 297 | 298 | In order to generate a code coverage report, you have to: 299 | 300 | - Enable `xdebug`: 301 | ```bash 302 | ddev xdebug 303 | ``` 304 | 305 | To generate a html report, you can run: 306 | ```bash 307 | ddev exec XDEBUG_MODE=coverage APPSEC_URL=http://crowdsec:7422 BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php ./my-code/crowdsec-bouncer-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/phpunit.xml 308 | 309 | ``` 310 | 311 | You should find the main report file `dashboard.html` in `tools/coding-standards/phpunit/code-coverage` folder. 312 | 313 | 314 | If you want to generate a text report in the same folder: 315 | 316 | ```bash 317 | ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=your-bouncer-key APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 AGENT_TLS_PATH=/var/www/html/cfssl MEMCACHED_DSN=memcached://memcached:11211 318 | REDIS_DSN=redis://redis:6379 /usr/bin/php ./my-code/crowdsec-bouncer-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/code-coverage/report.txt 319 | ``` 320 | 321 | With AppSec integration tests: 322 | 323 | ```bash 324 | ddev exec XDEBUG_MODE=coverage APPSEC_URL=http://crowdsec:7422 BOUNCER_KEY=your-bouncer-key 325 | LAPI_URL=https://crowdsec:8080 REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php 326 | ./my-code/crowdsec-bouncer-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/phpunit.xml 327 | ``` 328 | 329 | 330 | 331 | #### Generate CrowdSec tools and settings on start 332 | 333 | We use a post-start DDEV hook to: 334 | - Create a bouncer 335 | - Set bouncer key, api url and other needed values in the `scripts/auto-prepend/settings.php` file (useful to test 336 | standalone mode). 337 | - Create a watcher that we use in end-to-end tests 338 | 339 | Just copy the file and restart: 340 | ```bash 341 | cp .ddev/config_overrides/config.crowdsec.yaml .ddev/config.crowdsec.yaml 342 | ddev restart 343 | ``` 344 | 345 | #### Redis debug 346 | 347 | You should enter the `Redis` container: 348 | 349 | ```bash 350 | ddev exec -s redis redis-cli 351 | ``` 352 | 353 | Then, you could play with the `redis-cli` command line tool: 354 | 355 | - Display keys and databases: `INFO keyspace` 356 | 357 | - Display stored keys: `KEYS *` 358 | 359 | - Display key value: `GET [key]` 360 | 361 | - Remove a key: `DEL [key]` 362 | 363 | #### Memcached debug 364 | 365 | @see https://lzone.de/#/LZone%20Cheat%20Sheets/DevOps%20Services/memcached 366 | 367 | First, find the IP of the `Memcached` container: 368 | 369 | ```bash 370 | ddev find-ip memcached 371 | ``` 372 | 373 | Then, you could use `telnet` to interact with memcached: 374 | 375 | ``` 376 | telnet 11211 377 | ``` 378 | 379 | - `stats` 380 | 381 | - `stats items`: The first number after `items` is the slab id. Request a cache dump for each slab id, with a limit for 382 | the max number of keys to dump: 383 | 384 | - `stats cachedump 2 100` 385 | 386 | - `get ` : Read a value 387 | 388 | - `delete `: Delete a key 389 | 390 | 391 | ## Discover the CrowdSec LAPI 392 | 393 | This library interacts with a CrowdSec agent that you have installed on an accessible server. 394 | 395 | The easiest way to interact with the local API (LAPI) is to use the `cscli` tool,but it is also possible to contact it 396 | through a certain URL (e.g. `https://crowdsec:8080`). 397 | 398 | ### Use the CrowdSec cli (`cscli`) 399 | 400 | 401 | Please refer to the [CrowdSec cscli documentation](https://docs.crowdsec.net/docs/cscli/) for an exhaustive 402 | list of commands. 403 | 404 | **N.B**.: If you are using DDEV, just replace `cscli` with `ddev exec -s crowdsec cscli`. 405 | 406 | Here is a list of command that we often use to test the PHP library: 407 | 408 | #### Add decision for an IP or a range of IPs 409 | 410 | First example is a `ban`, second one is a `captcha`: 411 | 412 | ```bash 413 | cscli decisions add --ip --duration 12h --type ban 414 | cscli decisions add --ip --duration 4h --type captcha 415 | ``` 416 | 417 | For a range of IPs: 418 | 419 | ```bash 420 | cscli decisions add --range 1.2.3.4/30 --duration 12h --type ban 421 | ``` 422 | 423 | #### Add decision to ban or captcha a country 424 | ```bash 425 | cscli decisions add --scope Country --value JP --duration 4h --type ban 426 | ``` 427 | 428 | #### Delete decisions 429 | 430 | - Delete all decisions: 431 | ```bash 432 | cscli decisions delete --all 433 | ``` 434 | - Delete a decision with an IP scope 435 | ```bash 436 | cscli decisions delete -i 437 | ``` 438 | 439 | #### Create a bouncer 440 | 441 | 442 | ```bash 443 | cscli bouncers add -o raw 444 | ``` 445 | 446 | With DDEV, an alias is available: 447 | 448 | ```bash 449 | ddev create-bouncer 450 | ``` 451 | 452 | #### Create a watcher 453 | 454 | 455 | ```bash 456 | cscli machines add --password -o raw 457 | ``` 458 | 459 | With DDEV, an alias is available: 460 | 461 | ```bash 462 | ddev create-watcher 463 | ``` 464 | 465 | 466 | ### Use the web container to call LAPI 467 | 468 | Please see the [CrowdSec LAPI documentation](https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI) for an exhaustive list of available calls. 469 | 470 | If you are using DDEV, you can enter the web by running: 471 | 472 | ```bash 473 | ddev exec bash 474 | ```` 475 | 476 | Then, you should use some `curl` calls to contact the LAPI. 477 | 478 | For example, you can get the list of decisions with commands like: 479 | 480 | ```bash 481 | curl -H "X-Api-Key: " https://crowdsec:8080/v1/decisions | jq 482 | curl -H "X-Api-Key: " https://crowdsec:8080/v1/decisions?ip=1.2.3.4 | jq 483 | curl -H "X-Api-Key: " https://crowdsec:8080/v1/decisions/stream?startup=true | jq 484 | curl -H "X-Api-Key: " https://crowdsec:8080/v1/decisions/stream | jq 485 | ``` 486 | 487 | ## Commit message 488 | 489 | In order to have an explicit commit history, we are using some commits message convention with the following format: 490 | 491 | (): 492 | 493 | Allowed `type` are defined below. 494 | 495 | `scope` value intends to clarify which part of the code has been modified. It can be empty or `*` if the change is a 496 | global or difficult to assign to a specific part. 497 | 498 | `subject` describes what has been done using the imperative, present tense. 499 | 500 | Example: 501 | 502 | feat(admin): Add css for admin actions 503 | 504 | 505 | You can use the `commit-msg` git hook that you will find in the `.githooks` folder: 506 | 507 | ``` 508 | cp .githooks/commit-msg .git/hooks/commit-msg 509 | chmod +x .git/hooks/commit-msg 510 | ``` 511 | 512 | ### Allowed message `type` values 513 | 514 | - chore (automatic tasks; no production code change) 515 | - ci (updating continuous integration process; no production code change) 516 | - comment (commenting;no production code change) 517 | - docs (changes to the documentation) 518 | - feat (new feature for the user) 519 | - fix (bug fix for the user) 520 | - refactor (refactoring production code) 521 | - style (formatting; no production code change) 522 | - test (adding missing tests, refactoring tests; no production code change) 523 | 524 | ## Update documentation table of contents 525 | 526 | To update the table of contents in the documentation, you can use [the `doctoc` tool](https://github.com/thlorenz/doctoc). 527 | 528 | First, install it: 529 | 530 | ```bash 531 | npm install -g doctoc 532 | ``` 533 | 534 | Then, run it in the documentation folder: 535 | 536 | ```bash 537 | doctoc docs/* 538 | ``` 539 | 540 | 541 | ## Release process 542 | 543 | We are using [semantic versioning](https://semver.org/) to determine a version number. To verify the current tag, 544 | you should run: 545 | ``` 546 | git describe --tags `git rev-list --tags --max-count=1` 547 | ``` 548 | 549 | Before publishing a new release, there are some manual steps to take: 550 | 551 | - Change the version number in the `Constants.php` file 552 | - Update the `CHANGELOG.md` file 553 | 554 | Then, you have to [run the action manually from the GitHub repository](https://github.com/crowdsecurity/php-cs-bouncer/actions/workflows/release.yml) 555 | 556 | 557 | Alternatively, you could use the [GitHub CLI](https://github.com/cli/cli): 558 | - create a draft release: 559 | ``` 560 | gh workflow run release.yml -f tag_name=vx.y.z -f draft=true 561 | ``` 562 | - publish a prerelease: 563 | ``` 564 | gh workflow run release.yml -f tag_name=vx.y.z -f prerelease=true 565 | ``` 566 | - publish a release: 567 | ``` 568 | gh workflow run release.yml -f tag_name=vx.y.z 569 | ``` 570 | 571 | Note that the GitHub action will fail if the tag `tag_name` already exits. 572 | 573 | 574 | -------------------------------------------------------------------------------- /docs/INSTALLATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | 3 | # CrowdSec Bouncer PHP library 4 | 5 | ## Installation Guide 6 | 7 | 8 | 9 | 10 | **Table of Contents** 11 | 12 | - [Requirements](#requirements) 13 | - [Installation](#installation) 14 | 15 | 16 | 17 | 18 | ## Requirements 19 | 20 | - PHP >= 7.2.5 21 | - required PHP extensions: `ext-curl`, `ext-gd`, `ext-json`, `ext-mbstring` 22 | 23 | ## Installation 24 | 25 | Use `Composer` by simply adding `crowdsec/bouncer` as a dependency: 26 | 27 | ```shell 28 | composer require crowdsec/bouncer 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/TECHNICAL_NOTES.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | 3 | # CrowdSec Bouncer PHP library 4 | 5 | ## Technical notes 6 | 7 | 8 | 9 | **Table of Contents** 10 | 11 | - [Why use *Symfony/Cache* and *Symfony/Config* component?](#why-use-symfonycache-and-symfonyconfig-component) 12 | - [Why not using Guzzle?](#why-not-using-guzzle) 13 | - [Why not using Swagger Codegen?](#why-not-using-swagger-codegen) 14 | - [Which PHP compatibility matrix?](#which-php-compatibility-matrix) 15 | - [Why not PHP 5.6?](#why-not-php-56) 16 | - [Why not 7.0.x nor 7.1.x ?](#why-not-70x-nor-71x-) 17 | - [Memcached and PHP 8.x](#memcached-and-php-8x) 18 | 19 | 20 | 21 | 22 | We explain here each important technical decision used to design this library. 23 | 24 | ## Why use *Symfony/Cache* and *Symfony/Config* component? 25 | 26 | The Cache component is compatible with many cache systems. 27 | 28 | The Config component provides several classes to help you find, load, combine, fill and validate configuration values of any kind, whatever their source may be (YAML, XML, INI files, or for instance a database). A great job done by this library, tested and maintained under LTS versions. 29 | 30 | This library is tested and maintained under LTS versions. 31 | 32 | ## Why not using Guzzle? 33 | 34 | The last Guzzle versions remove the User-Agent to prevent risks. Since LAPI or CAPI need a valid User-Agent, we can not use Guzzle to request CAPI/LAPI. 35 | 36 | ## Why not using Swagger Codegen? 37 | 38 | We were not able to use this client with ease ex: impossible to get JSON data, it seems there is a bug with unserialization, we received an empty array. 39 | 40 | ## Which PHP compatibility matrix? 41 | 42 | ### Why not PHP 5.6? 43 | 44 | Because this PHP version is no more supported since December 2018 (not even a security fix). Also, a lot of libraries are no more compatible with this version. We don't want to use an older version of these libraries because Composer can only install one version of each extension/package. So, being compatible with this old PHP version means to be not compatible with projects using a new version of these libraries. 45 | 46 | ### Why not 7.0.x nor 7.1.x ? 47 | 48 | These PHP versions are not anymore maintained for security fixes since 2019. We encourage you a lot to upgrade your PHP version. You can view the [full list of PHP versions lifecycle](https://www.php.net/supported-versions.php). 49 | 50 | To get a robust library and not provide security bug unmaintained, we use [components](https://packagist.org/packages/symfony/cache#v3.4.47) under [LTS versioning](https://symfony.com/releases/3.4). 51 | 52 | The oldest PHP version compatible with these libraries is PHP 7.2.x. 53 | 54 | 55 | ### Memcached and PHP 8.x 56 | 57 | In order to use Memcached with a PHP 8.x set up, you must have an installed version of the memcached php extension > 3.1.5. To check what is your current version, you could run : 58 | 59 | `php -r "echo phpversion('memcached');"` 60 | 61 | -------------------------------------------------------------------------------- /docs/USER_GUIDE.md: -------------------------------------------------------------------------------- 1 | ![CrowdSec Logo](images/logo_crowdsec.png) 2 | # CrowdSec Bouncer PHP library 3 | 4 | ## User Guide 5 | 6 | 7 | 8 | 9 | **Table of Contents** 10 | 11 | - [Description](#description) 12 | - [Prerequisites](#prerequisites) 13 | - [Features](#features) 14 | - [Usage](#usage) 15 | - [Create your own bouncer](#create-your-own-bouncer) 16 | - [Implementation](#implementation) 17 | - [Test your bouncer](#test-your-bouncer) 18 | - [Configurations](#configurations) 19 | - [Bouncer behavior](#bouncer-behavior) 20 | - [Local API Connection](#local-api-connection) 21 | - [Cache](#cache) 22 | - [Geolocation](#geolocation) 23 | - [Captcha and ban wall settings](#captcha-and-ban-wall-settings) 24 | - [Debug](#debug) 25 | - [Security note](#security-note) 26 | - [Other ready to use PHP bouncers](#other-ready-to-use-php-bouncers) 27 | 28 | 29 | 30 | 31 | ## Description 32 | 33 | This library allows you to create CrowdSec LAPI bouncers for PHP applications or frameworks like e-commerce, blog or other 34 | exposed applications. 35 | 36 | ## Prerequisites 37 | 38 | To be able to use a bouncer based on this library, 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. 39 | 40 | Please note that first and foremost a CrowdSec agent must be installed on a server that is accessible by this library. 41 | 42 | ## Features 43 | 44 | - CrowdSec Local API support 45 | - Handle `ip`, `range` and `country` scoped decisions 46 | - `Live mode` or `Stream mode` 47 | - AppSec support 48 | - Usage metrics support 49 | - Support IpV4 and Ipv6 (Ipv6 range decisions are yet only supported in `Live mode`) 50 | - Large PHP matrix compatibility: from 7.2 to 8.4 51 | - Built-in support for the most known cache systems Redis, Memcached and PhpFiles 52 | - Clear, prune and refresh the bouncer cache 53 | 54 | 55 | ## Usage 56 | 57 | When a user is suspected by CrowdSec to be malevolent, a bouncer would either display a captcha to resolve or 58 | simply a page notifying that access is denied. If the user is considered as a clean user, he/she will access the page 59 | as normal. 60 | 61 | A ban wall could look like: 62 | 63 | ![Ban wall](images/screenshots/front-ban.jpg) 64 | 65 | A captcha wall could look like: 66 | 67 | ![Captcha wall](images/screenshots/front-captcha.jpg) 68 | 69 | Please note that it is possible to customize all the colors of these pages so that they integrate best with your design. 70 | 71 | On the other hand, all texts are also fully customizable. This will allow you, for example, to present translated pages in your users' language. 72 | 73 | ## Create your own bouncer 74 | 75 | ### Implementation 76 | 77 | You can use this library to develop your own PHP application bouncer. Any custom bouncer should extend the [`AbstractBouncer`](../src/AbstractBouncer.php) class. 78 | 79 | ```php 80 | namespace MyNameSpace; 81 | 82 | use CrowdSecBouncer\AbstractBouncer; 83 | 84 | class MyCustomBouncer extends AbstractBouncer 85 | { 86 | } 87 | ``` 88 | 89 | Then, you will have to implement all necessary methods : 90 | 91 | ```php 92 | namespace MyNameSpace; 93 | 94 | use CrowdSecBouncer\AbstractBouncer; 95 | 96 | class MyCustomBouncer extends AbstractBouncer 97 | { 98 | /** 99 | * Get current http method 100 | */ 101 | public function getHttpMethod(): string 102 | { 103 | // Your implementation 104 | } 105 | 106 | /** 107 | * Get value of an HTTP request header. Ex: "X-Forwarded-For" 108 | */ 109 | public function getHttpRequestHeader(string $name): ?string 110 | { 111 | // Your implementation 112 | } 113 | 114 | /** 115 | * Get the value of a posted field. 116 | */ 117 | public function getPostedVariable(string $name): ?string 118 | { 119 | // Your implementation 120 | } 121 | 122 | /** 123 | * Get the current IP, even if it's the IP of a proxy 124 | */ 125 | public function getRemoteIp(): string 126 | { 127 | // Your implementation 128 | } 129 | 130 | /** 131 | * Get current request uri 132 | */ 133 | public function getRequestUri(): string 134 | { 135 | // Your implementation 136 | } 137 | 138 | /** 139 | * Get current request headers 140 | */ 141 | public function getRequestHeaders(): array 142 | { 143 | // Your implementation 144 | } 145 | 146 | /** 147 | * Get the raw body of the current request 148 | */ 149 | public function getRequestRawBody(): string 150 | { 151 | // Your implementation 152 | } 153 | 154 | /** 155 | * Get the host of the current request 156 | */ 157 | public function getRequestHost() : string 158 | { 159 | // Your implementation 160 | } 161 | 162 | /** 163 | * Get the user agent of the current request 164 | */ 165 | public function getRequestUserAgent() : string 166 | { 167 | // Your implementation 168 | } 169 | 170 | } 171 | ``` 172 | 173 | 174 | Once you have implemented these methods, you could retrieve all required configurations to instantiate your bouncer 175 | and then call the `run` method to apply a bounce for the current detected IP. Please see below for the list of 176 | available configurations. 177 | 178 | In order to instantiate the bouncer, you will have to create at least a `CrowdSec\RemediationEngine\LapiRemediation` 179 | object too. 180 | 181 | 182 | ```php 183 | use MyNameSpace\MyCustomBouncer; 184 | use CrowdSec\RemediationEngine\LapiRemediation; 185 | use CrowdSec\LapiClient\Bouncer as BouncerClient; 186 | use CrowdSec\RemediationEngine\CacheStorage\PhpFiles; 187 | 188 | $configs = [...]; 189 | $client = new BouncerClient($configs);// @see AbstractBouncer::handleClient method for a basic client creation 190 | $cacheStorage = new PhpFiles($configs);// @see AbstractBouncer::handleCache method for a basic cache storage creation 191 | $remediationEngine = new LapiRemediation($configs, $client, $cacheStorage); 192 | 193 | $bouncer = new MyCustomBouncer($configs, $remediationEngine); 194 | 195 | $bouncer->run(); 196 | ``` 197 | 198 | 199 | ### Test your bouncer 200 | 201 | To test your bouncer, you could add decision to ban your own IP for 5 minutes for example: 202 | 203 | ```bash 204 | cscli decisions add --ip --duration 5m --type ban 205 | ``` 206 | 207 | You can also test a captcha: 208 | 209 | ```bash 210 | cscli decisions delete --all # be careful with this command! 211 | cscli decisions add --ip --duration 15m --type captcha 212 | ``` 213 | 214 | 215 | To go further and learn how to include this library in your project, you should follow the [`DEVELOPER GUIDE`](DEVELOPER.md). 216 | 217 | ## Configurations 218 | 219 | The first parameter of the `AbstractBouncer` class constructor method is an array of settings. 220 | 221 | Below is the list of available settings: 222 | 223 | ### Bouncer behavior 224 | 225 | - `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. 226 | 227 | - `use_appsec`: true to enable AppSec support. Default to false. If enabled, the bouncer will check the AppSec decisions if the IP is not found in the Local API decisions. 228 | 229 | - `fallback_remediation`: Select from `bypass` (minimum remediation), `captcha` or `ban` (maximum remediation). Default to 'captcha'. Handle unknown remediations as. 230 | 231 | - `trust_ip_forward_array`: If you use a CDN, a reverse proxy or a load balancer, set an array of comparable IPs arrays: 232 | (example: `[['001.002.003.004', '001.002.003.004'], ['005.006.007.008', '005.006.007.008']]` for CDNs with IPs `1.2.3.4` and `5.6.7.8`). For other IPs, the bouncer will not trust the X-Forwarded-For header. 233 | 234 | - `excluded_uris`: array of URIs that will not be bounced. 235 | 236 | - `stream_mode`: true to enable stream mode, false to enable the live mode. Default to false. By default, the `live mode` is enabled. The first time a stranger connects to your website, this mode means that the IP will be checked directly by the CrowdSec API. The rest of your user’s browsing will be even more transparent thanks to the fully customizable cache system. But you can also activate the `stream mode`. This mode allows you to constantly feed the bouncer with the malicious IP list via a background task (CRON), making it to be even faster when checking the IP of your visitors. Besides, if your site has a lot of unique visitors at the same time, this will not influence the traffic to the API of your CrowdSec instance. 237 | 238 | ### Local API Connection 239 | 240 | - `auth_type`: Select from `api_key` and `tls`. Choose if you want to use an API-KEY or a TLS (pki) authentication. 241 | TLS authentication is only available if you use CrowdSec agent with a version superior to 1.4.0. 242 | 243 | 244 | - `api_key`: Key generated by the cscli (CrowdSec cli) command like `cscli bouncers add bouncer-php-library`. 245 | Only required if you choose `api_key` as `auth_type`. 246 | 247 | 248 | - `tls_cert_path`: absolute path to the bouncer certificate (e.g. pem file). 249 | Only required if you choose `tls` as `auth_type`. 250 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 251 | 252 | 253 | - `tls_key_path`: Absolute path to the bouncer key (e.g. pem file). 254 | Only required if you choose `tls` as `auth_type`. 255 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 256 | 257 | 258 | - `tls_verify_peer`: This option determines whether request handler verifies the authenticity of the peer's certificate. 259 | Only required if you choose `tls` as `auth_type`. 260 | When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. 261 | If `tls_verify_peer` is set to true, request handler verifies whether the certificate is authentic. 262 | This trust is based on a chain of digital signatures, 263 | rooted in certification authority (CA) certificates you supply using the `tls_ca_cert_path` setting below. 264 | 265 | 266 | - `tls_ca_cert_path`: Absolute path to the CA used to process peer verification. 267 | Only required if you choose `tls` as `auth_type` and `tls_verify_peer` is set to true. 268 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 269 | 270 | 271 | - `api_url`: Define the URL to your Local API server, default to `http://localhost:8080`. 272 | 273 | 274 | - `api_timeout`: In seconds. The global timeout when calling Local API. Default to 120 sec. If set to a negative value 275 | or 0, timeout will be unlimited. 276 | 277 | 278 | - `api_connect_timeout`: In seconds. **Only for curl**. The timeout for the connection phase when calling Local API. 279 | Default to 300 sec. If set to a 0, timeout will be unlimited. 280 | 281 | 282 | ### Cache 283 | 284 | 285 | - `fs_cache_path`: Will be used only if you choose PHP file cache as cache storage. 286 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 287 | 288 | 289 | - `redis_dsn`: Will be used only if you choose Redis cache as cache storage. 290 | 291 | 292 | - `memcached_dsn`: Will be used only if you choose Memcached as cache storage. 293 | 294 | 295 | - `clean_ip_cache_duration`: Set the duration we keep in cache the fact that an IP is clean. In seconds. Defaults to 5. 296 | 297 | 298 | - `bad_ip_cache_duration`: Set the duration we keep in cache the fact that an IP is bad. In seconds. Defaults to 20. 299 | 300 | 301 | - `captcha_cache_duration`: Set the duration we keep in cache the captcha flow variables for an IP. In seconds. 302 | Defaults to 86400. 303 | 304 | 305 | ### Geolocation 306 | 307 | - `geolocation`: Settings for geolocation remediation (i.e. country based remediation). 308 | 309 | - `geolocation[enabled]`: true to enable remediation based on country. Default to false. 310 | 311 | - `geolocation[type]`: Geolocation system. Only 'maxmind' is available for the moment. Default to `maxmind`. 312 | 313 | - `geolocation[cache_duration]`: This setting will be used to set the lifetime (in seconds) of a cached country 314 | 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. 315 | 316 | - `geolocation[maxmind]`: MaxMind settings. 317 | 318 | - `geolocation[maxmind][database_type]`: Select from `country` or `city`. Default to `country`. These are the two available MaxMind database types. 319 | 320 | - `geolocation[maxmind][database_path]`: Absolute path to the MaxMind database (e.g. mmdb file) 321 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 322 | 323 | 324 | ### Captcha and ban wall settings 325 | 326 | 327 | - `hide_mentions`: true to hide CrowdSec mentions on ban and captcha walls. 328 | 329 | 330 | - `custom_css`: Custom css directives for ban and captcha walls 331 | 332 | 333 | - `color`: Array of settings for ban and captcha walls colors. 334 | 335 | - `color[text][primary]` 336 | 337 | - `color[text][secondary]` 338 | 339 | - `color[text][button]` 340 | 341 | - `color[text][error_message]` 342 | 343 | - `color[background][page]` 344 | 345 | - `color[background][container]` 346 | 347 | - `color[background][button]` 348 | 349 | - `color[background][button_hover]` 350 | 351 | 352 | - `text`: Array of settings for ban and captcha walls texts. 353 | 354 | - `text[captcha_wall][tab_title]` 355 | 356 | - `text[captcha_wall][title]` 357 | 358 | - `text[captcha_wall][subtitle]` 359 | 360 | - `text[captcha_wall][refresh_image_link]` 361 | 362 | - `text[captcha_wall][captcha_placeholder]` 363 | 364 | - `text[captcha_wall][send_button]` 365 | 366 | - `text[captcha_wall][error_message]` 367 | 368 | - `text[captcha_wall][footer]` 369 | 370 | - `text[ban_wall][tab_title]` 371 | 372 | - `text[ban_wall][title]` 373 | 374 | - `text[ban_wall][subtitle]` 375 | 376 | - `text[ban_wall][footer]` 377 | 378 | 379 | ### Debug 380 | - `debug_mode`: `true` to enable verbose debug log. Default to `false`. 381 | 382 | 383 | - `disable_prod_log`: `true` to disable prod log. Default to `false`. 384 | 385 | 386 | - `log_directory_path`: Absolute path to store log files. 387 | **Make sure this path is not publicly accessible.** [See security note below](#security-note). 388 | 389 | 390 | - `display_errors`: true to stop the process and display errors on browser if any. 391 | 392 | 393 | - `forced_test_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used instead of the 394 | real remote ip. 395 | 396 | 397 | - `forced_test_forwarded_ip`: Only for test or debug purpose. Default to empty. If not empty, it will be used 398 | instead of the real forwarded ip. If set to `no_forward`, the x-forwarded-for mechanism will not be used at all. 399 | 400 | ### Security note 401 | 402 | Some files should not be publicly accessible because they may contain sensitive data: 403 | 404 | - Log files 405 | - Cache files of the File system cache 406 | - TLS authentication files 407 | - Geolocation database files 408 | 409 | If you define publicly accessible folders in the settings, be sure to add rules to deny access to these files. 410 | 411 | In the following example, we will suppose that you use a folder `crowdsec` with sub-folders `logs`, `cache`, `tls` and `geolocation`. 412 | 413 | If you are using Nginx, you could use the following snippet to modify your website configuration file: 414 | 415 | ```nginx 416 | server { 417 | ... 418 | ... 419 | ... 420 | # Deny all attempts to access some folders of the crowdsec bouncer lib 421 | location ~ /crowdsec/(logs|cache|tls|geolocation) { 422 | deny all; 423 | } 424 | ... 425 | ... 426 | } 427 | ``` 428 | 429 | If you are using Apache, you could add this kind of directive in a `.htaccess` file: 430 | 431 | ```htaccess 432 | Redirectmatch 403 crowdsec/logs/ 433 | Redirectmatch 403 crowdsec/cache/ 434 | Redirectmatch 403 crowdsec/tls/ 435 | Redirectmatch 403 crowdsec/geolocation/ 436 | ``` 437 | 438 | **N.B.:** 439 | - There is no need to protect the `cache` folder if you are using Redis or Memcached cache systems. 440 | - There is no need to protect the `logs` folder if you disable debug and prod logging. 441 | - There is no need to protect the `tls` folder if you use Bouncer API key authentication type. 442 | - There is no need to protect the `geolocation` folder if you don't use the geolocation feature. 443 | 444 | ## Other ready to use PHP bouncers 445 | 446 | To have a more concrete idea on how to develop a bouncer from this library, you may look at the following bouncers : 447 | - [CrowdSec Bouncer extension for Magento 2](https://github.com/crowdsecurity/cs-magento-bouncer) 448 | - [CrowdSec Bouncer plugin for WordPress ](https://github.com/crowdsecurity/cs-wordpress-bouncer) 449 | - [CrowdSec Standalone Bouncer ](https://github.com/crowdsecurity/cs-standalone-php-bouncer) 450 | 451 | -------------------------------------------------------------------------------- /docs/images/logo_crowdsec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/php-cs-bouncer/bc1fe9531619506e6ef1c2fd17360c3b30cf77aa/docs/images/logo_crowdsec.png -------------------------------------------------------------------------------- /docs/images/screenshots/front-ban.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/php-cs-bouncer/bc1fe9531619506e6ef1c2fd17360c3b30cf77aa/docs/images/screenshots/front-ban.jpg -------------------------------------------------------------------------------- /docs/images/screenshots/front-captcha.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/php-cs-bouncer/bc1fe9531619506e6ef1c2fd17360c3b30cf77aa/docs/images/screenshots/front-captcha.jpg -------------------------------------------------------------------------------- /src/AbstractBouncer.php: -------------------------------------------------------------------------------- 1 | pushHandler(new NullHandler()); 57 | } 58 | // @codeCoverageIgnoreEnd 59 | $this->logger = $logger; 60 | $this->remediationEngine = $remediationEngine; 61 | $this->configure($configs); 62 | $configs = $this->getConfigs(); 63 | // Clean configs for lighter log 64 | unset($configs['text'], $configs['color']); 65 | $this->logger->debug('Instantiate bouncer', [ 66 | 'type' => 'BOUNCER_INIT', 67 | 'logger' => \get_class($this->getLogger()), 68 | 'remediation' => \get_class($this->getRemediationEngine()), 69 | 'configs' => $configs, 70 | ]); 71 | } 72 | 73 | /** 74 | * Apply a bounce for current IP depending on remediation associated to this IP 75 | * (e.g. display a ban wall, captcha wall or do nothing in case of a bypass). 76 | * 77 | * @throws CacheException 78 | * @throws InvalidArgumentException 79 | */ 80 | public function bounceCurrentIp(): void 81 | { 82 | // Retrieve the current IP (even if it is a proxy IP) or a testing IP 83 | $forcedTestIp = $this->getConfig('forced_test_ip'); 84 | $ip = $forcedTestIp ?: $this->getRemoteIp(); 85 | $ip = $this->handleForwardedFor($ip, $this->configs); 86 | $this->logger->info('Bouncing current IP', [ 87 | 'ip' => $ip, 88 | ]); 89 | $remediationData = $this->getRemediation($ip); 90 | $this->handleRemediation( 91 | $remediationData[Constants::REMEDIATION_KEY], 92 | $ip, 93 | $remediationData[Constants::ORIGIN_KEY] 94 | ); 95 | } 96 | 97 | /** 98 | * This method clear the full data in cache. 99 | * 100 | * @return bool If the cache has been successfully cleared or not 101 | * 102 | * @throws BouncerException 103 | */ 104 | public function clearCache(): bool 105 | { 106 | try { 107 | return $this->getRemediationEngine()->clearCache(); 108 | } catch (\Throwable $e) { 109 | throw new BouncerException('Error while clearing cache: ' . $e->getMessage(), (int) $e->getCode(), $e); 110 | } 111 | } 112 | 113 | /** 114 | * Get the remediation for the specified IP using AppSec. 115 | * 116 | * @throws BouncerException 117 | * @throws CacheException 118 | */ 119 | public function getAppSecRemediationForIp(string $ip): array 120 | { 121 | try { 122 | return $this->remediationEngine->getAppSecRemediation( 123 | $this->getAppSecHeaders($ip), 124 | $this->getRequestRawBody() 125 | ); 126 | } catch (\Throwable $e) { 127 | throw new BouncerException($e->getMessage(), (int) $e->getCode(), $e); 128 | } 129 | } 130 | 131 | /** 132 | * Retrieve Bouncer configuration by name. 133 | */ 134 | public function getConfig(string $name) 135 | { 136 | return (isset($this->configs[$name])) ? $this->configs[$name] : null; 137 | } 138 | 139 | /** 140 | * Retrieve Bouncer configurations. 141 | */ 142 | public function getConfigs(): array 143 | { 144 | return $this->configs; 145 | } 146 | 147 | /** 148 | * Get current http method. 149 | */ 150 | abstract public function getHttpMethod(): string; 151 | 152 | /** 153 | * Get value of an HTTP request header. Ex: "X-Forwarded-For". 154 | */ 155 | abstract public function getHttpRequestHeader(string $name): ?string; 156 | 157 | /** 158 | * Returns the logger instance. 159 | * 160 | * @return LoggerInterface the logger used by this library 161 | */ 162 | public function getLogger(): LoggerInterface 163 | { 164 | return $this->logger; 165 | } 166 | 167 | /** 168 | * Get the value of a posted field. 169 | */ 170 | abstract public function getPostedVariable(string $name): ?string; 171 | 172 | public function getRemediationEngine(): LapiRemediation 173 | { 174 | return $this->remediationEngine; 175 | } 176 | 177 | /** 178 | * Get the remediation for the specified IP. 179 | * 180 | * @return array 181 | * [ 182 | * 'remediation': the remediation to apply (ex: 'ban', 'captcha', 'bypass') 183 | * 'origin': the origin of the remediation (ex: 'cscli', 'CAPI') 184 | * ] 185 | * 186 | * @throws BouncerException 187 | * @throws CacheException 188 | */ 189 | public function getRemediationForIp(string $ip): array 190 | { 191 | try { 192 | return $this->getRemediationEngine()->getIpRemediation($ip); 193 | } catch (\Throwable $e) { 194 | throw new BouncerException($e->getMessage(), (int) $e->getCode(), $e); 195 | } 196 | } 197 | 198 | /** 199 | * Get the current IP, even if it's the IP of a proxy. 200 | */ 201 | abstract public function getRemoteIp(): string; 202 | 203 | /** 204 | * Get current request headers. 205 | */ 206 | abstract public function getRequestHeaders(): array; 207 | 208 | /** 209 | * Get current request host. 210 | */ 211 | abstract public function getRequestHost(): string; 212 | 213 | /** 214 | * Get current request raw body. 215 | */ 216 | abstract public function getRequestRawBody(): string; 217 | 218 | /** 219 | * Get current request uri. 220 | */ 221 | abstract public function getRequestUri(): string; 222 | 223 | /** 224 | * Get current request user agent. 225 | */ 226 | abstract public function getRequestUserAgent(): string; 227 | 228 | /** 229 | * Check if the bouncer is connected to a "Blocklist as a service" Lapi. 230 | */ 231 | public function hasBaasUri(): bool 232 | { 233 | $url = $this->getRemediationEngine()->getClient()->getConfig('api_url'); 234 | 235 | return 0 === strpos($url, Constants::BAAS_URL); 236 | } 237 | 238 | /** 239 | * This method prune the cache: it removes all the expired cache items. 240 | * 241 | * @return bool If the cache has been successfully pruned or not 242 | * 243 | * @throws BouncerException 244 | */ 245 | public function pruneCache(): bool 246 | { 247 | try { 248 | return $this->getRemediationEngine()->pruneCache(); 249 | } catch (\Throwable $e) { 250 | throw new BouncerException('Error while pruning cache: ' . $e->getMessage(), (int) $e->getCode(), $e); 251 | } 252 | } 253 | 254 | /** 255 | * @throws BouncerException 256 | * @throws CacheException 257 | */ 258 | public function pushUsageMetrics( 259 | string $bouncerName, 260 | string $bouncerVersion, 261 | string $bouncerType = LapiConstants::METRICS_TYPE 262 | ): array { 263 | try { 264 | return $this->remediationEngine->pushUsageMetrics($bouncerName, $bouncerVersion, $bouncerType); 265 | } catch (\Throwable $e) { 266 | throw new BouncerException($e->getMessage(), (int) $e->getCode(), $e); 267 | } 268 | } 269 | 270 | /** 271 | * Used in stream mode only. 272 | * This method should be called periodically (ex: crontab) in an asynchronous way to update the bouncer cache. 273 | * 274 | * @return array Number of deleted and new decisions 275 | * 276 | * @throws BouncerException 277 | * @throws CacheException 278 | */ 279 | public function refreshBlocklistCache(): array 280 | { 281 | try { 282 | return $this->getRemediationEngine()->refreshDecisions(); 283 | } catch (\Throwable $e) { 284 | $message = 'Error while refreshing decisions: ' . $e->getMessage(); 285 | throw new BouncerException($message, (int) $e->getCode(), $e); 286 | } 287 | } 288 | 289 | /** 290 | * @throws InvalidArgumentException 291 | */ 292 | public function resetUsageMetrics(): void 293 | { 294 | // Retrieve metrics cache item 295 | $metricsItem = $this->getRemediationEngine()->getCacheStorage()->getItem(AbstractCache::ORIGINS_COUNT); 296 | if ($metricsItem->isHit()) { 297 | // Reset the metrics 298 | $metricsItem->set([]); 299 | $this->getRemediationEngine()->getCacheStorage()->getAdapter()->save($metricsItem); 300 | } 301 | } 302 | 303 | /** 304 | * Handle a bounce for current IP. 305 | * 306 | * @throws CacheException 307 | * @throws InvalidArgumentException 308 | * @throws BouncerException 309 | */ 310 | public function run(): bool 311 | { 312 | $result = false; 313 | try { 314 | if ($this->shouldBounceCurrentIp()) { 315 | $this->bounceCurrentIp(); 316 | $result = true; 317 | } 318 | } catch (\Throwable $e) { 319 | $this->logger->error('Something went wrong during bouncing', [ 320 | 'type' => 'EXCEPTION_WHILE_BOUNCING', 321 | 'message' => $e->getMessage(), 322 | 'code' => $e->getCode(), 323 | 'file' => $e->getFile(), 324 | 'line' => $e->getLine(), 325 | ]); 326 | if (true === $this->getConfig('display_errors')) { 327 | throw new BouncerException($e->getMessage(), (int) $e->getCode(), $e); 328 | } 329 | } 330 | 331 | return $result; 332 | } 333 | 334 | /** 335 | * If the current IP should be bounced or not, matching custom business rules. 336 | * Must be called before trying to get remediation for the current IP, so that origins count is not already updated. 337 | * 338 | * @throws CacheException 339 | * @throws InvalidArgumentException 340 | */ 341 | public function shouldBounceCurrentIp(): bool 342 | { 343 | $excludedURIs = $this->getConfig('excluded_uris') ?? []; 344 | $uri = $this->getRequestUri(); 345 | if ($uri && \in_array($uri, $excludedURIs)) { 346 | return $this->handleBounceExclusion('This URI is excluded from bouncing: ' . $uri); 347 | } 348 | if (Constants::BOUNCING_LEVEL_DISABLED === $this->getRemediationEngine()->getConfig('bouncing_level')) { 349 | return $this->handleBounceExclusion('Bouncing is disabled by bouncing_level configuration'); 350 | } 351 | 352 | return true; 353 | } 354 | 355 | /** 356 | * Process a simple cache test. 357 | * 358 | * @throws BouncerException 359 | * @throws CacheStorageException 360 | * @throws CacheException 361 | */ 362 | public function testCacheConnection(): void 363 | { 364 | try { 365 | $cache = $this->getRemediationEngine()->getCacheStorage(); 366 | $cache->getItem(AbstractCache::CONFIG); 367 | } catch (\Throwable $e) { 368 | $message = 'Error while testing cache connection: ' . $e->getMessage(); 369 | throw new BouncerException($message, (int) $e->getCode(), $e); 370 | } 371 | } 372 | 373 | /** 374 | * Method based on superglobals to retrieve the raw body of the request. 375 | * If the body is too big (greater than the "appsec_max_body_size_kb" configuration), 376 | * it will be truncated to the maximum size + 1 kB. 377 | * In case of error, an empty string is returned. 378 | * 379 | * @param resource $stream The stream to read the body from 380 | * 381 | * @see https://www.php.net/manual/en/language.variables.superglobals.php 382 | */ 383 | protected function buildRequestRawBody($stream): string 384 | { 385 | if (!is_resource($stream)) { 386 | $this->logger->error('Invalid stream resource', [ 387 | 'type' => 'BUILD_RAW_BODY', 388 | ]); 389 | 390 | return ''; 391 | } 392 | $maxBodySize = $this->getRemediationEngine()->getConfig('appsec_max_body_size_kb') ?? 393 | Constants::APPSEC_DEFAULT_MAX_BODY_SIZE; 394 | 395 | try { 396 | return $this->buildRawBodyFromSuperglobals($maxBodySize, $stream, $_SERVER, $_POST, $_FILES); 397 | } catch (BouncerException $e) { 398 | $this->logger->error('Error while building raw body', [ 399 | 'type' => 'BUILD_RAW_BODY', 400 | 'message' => $e->getMessage(), 401 | 'code' => $e->getCode(), 402 | ]); 403 | 404 | return ''; 405 | } 406 | } 407 | 408 | /** 409 | * Returns a default "CrowdSec 403" HTML template. 410 | * The input $config should match the TemplateConfiguration input format. 411 | * 412 | * @return string The HTML compiled template 413 | */ 414 | protected function getBanHtml(): string 415 | { 416 | $template = new Template('ban.html.twig'); 417 | 418 | return $template->render($this->configs); 419 | } 420 | 421 | /** 422 | * Returns a default "CrowdSec Captcha (401)" HTML template. 423 | */ 424 | protected function getCaptchaHtml( 425 | bool $error, 426 | string $captchaImageSrc, 427 | string $captchaResolutionFormUrl 428 | ): string { 429 | $template = new Template('captcha.html.twig'); 430 | 431 | return $template->render(array_merge( 432 | $this->configs, 433 | [ 434 | 'error' => $error, 435 | 'captcha_img' => $captchaImageSrc, 436 | 'captcha_resolution_url' => $captchaResolutionFormUrl, 437 | ] 438 | )); 439 | } 440 | 441 | /** 442 | * @throws BouncerException 443 | * @throws CacheStorageException 444 | */ 445 | protected function handleCache(array $configs, LoggerInterface $logger): AbstractCache 446 | { 447 | $cacheSystem = $configs['cache_system'] ?? Constants::CACHE_SYSTEM_PHPFS; 448 | switch ($cacheSystem) { 449 | case Constants::CACHE_SYSTEM_PHPFS: 450 | $cache = new PhpFiles($configs, $logger); 451 | break; 452 | case Constants::CACHE_SYSTEM_MEMCACHED: 453 | $cache = new Memcached($configs, $logger); 454 | break; 455 | case Constants::CACHE_SYSTEM_REDIS: 456 | $cache = new Redis($configs, $logger); 457 | break; 458 | default: 459 | throw new BouncerException("Unknown selected cache technology: $cacheSystem"); 460 | } 461 | 462 | return $cache; 463 | } 464 | 465 | protected function handleClient(array $configs, LoggerInterface $logger): BouncerClient 466 | { 467 | $requestHandler = empty($configs['use_curl']) ? new FileGetContents($configs) : new Curl($configs); 468 | 469 | return new BouncerClient($configs, $requestHandler, $logger); 470 | } 471 | 472 | /** 473 | * Handle remediation for a given IP and origin. 474 | * 475 | * @throws CacheException 476 | * @throws InvalidArgumentException 477 | * @throws BouncerException 478 | */ 479 | protected function handleRemediation(string $remediation, string $ip, string $origin): void 480 | { 481 | switch ($remediation) { 482 | case Constants::REMEDIATION_CAPTCHA: 483 | $this->handleCaptchaRemediation($ip, $origin); 484 | break; 485 | case Constants::REMEDIATION_BAN: 486 | $this->logger->debug('Will display a ban wall', [ 487 | 'ip' => $ip, 488 | ]); 489 | // Increment ban origin count 490 | $this->getRemediationEngine()->updateMetricsOriginsCount( 491 | $origin, 492 | $remediation 493 | ); 494 | $this->handleBanRemediation(); 495 | break; 496 | case Constants::REMEDIATION_BYPASS: 497 | default: 498 | // Increment clean origin count 499 | $finalOrigin = AbstractCache::CLEAN_APPSEC === $origin ? 500 | AbstractCache::CLEAN_APPSEC : 501 | AbstractCache::CLEAN; 502 | $this->getRemediationEngine()->updateMetricsOriginsCount( 503 | $finalOrigin, 504 | Constants::REMEDIATION_BYPASS 505 | ); 506 | break; 507 | } 508 | } 509 | 510 | /** 511 | * @codeCoverageIgnore 512 | */ 513 | protected function redirectResponse(string $redirect): void 514 | { 515 | header("Location: $redirect"); 516 | exit(0); 517 | } 518 | 519 | /** 520 | * Send HTTP response. 521 | * 522 | * @throws BouncerException 523 | * 524 | * @codeCoverageIgnore 525 | */ 526 | protected function sendResponse(string $body, int $statusCode): void 527 | { 528 | switch ($statusCode) { 529 | case 401: 530 | header('HTTP/1.0 401 Unauthorized'); 531 | header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 532 | header('Cache-Control: post-check=0, pre-check=0', false); 533 | header('Pragma: no-cache'); 534 | break; 535 | case 403: 536 | header('HTTP/1.0 403 Forbidden'); 537 | header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 538 | header('Cache-Control: post-check=0, pre-check=0', false); 539 | header('Pragma: no-cache'); 540 | break; 541 | default: 542 | throw new BouncerException("Unhandled code $statusCode"); 543 | } 544 | 545 | echo $body; 546 | 547 | exit(0); 548 | } 549 | 550 | /** 551 | * Build a captcha couple. 552 | * 553 | * @return array an array composed of two items, a "phrase" string representing the phrase and a "inlineImage" 554 | * representing the image data 555 | */ 556 | private function buildCaptchaCouple(): array 557 | { 558 | $captchaBuilder = new CaptchaBuilder(); 559 | 560 | return [ 561 | 'phrase' => $captchaBuilder->getPhrase(), 562 | 'inlineImage' => $captchaBuilder->build()->inline(), 563 | ]; 564 | } 565 | 566 | /** 567 | * Check if the captcha filled by the user is correct or not. 568 | * We are permissive with the user: 569 | * - case is not sensitive 570 | * - (0 is interpreted as "o" and 1 in interpreted as "l"). 571 | * 572 | * @param string $expected The expected phrase 573 | * @param string $try The phrase to check (the user input) 574 | * @param string $ip The IP of the use (for logging purpose) 575 | * 576 | * @return bool If the captcha input was correct or not 577 | * 578 | * @SuppressWarnings(PHPMD.StaticAccess) 579 | */ 580 | private function checkCaptcha(string $expected, string $try, string $ip): bool 581 | { 582 | $solved = PhraseBuilder::comparePhrases($expected, $try); 583 | $this->logger->info('Captcha has been solved', [ 584 | 'type' => 'CAPTCHA_SOLVED', 585 | 'ip' => $ip, 586 | 'resolution' => $solved, 587 | ]); 588 | 589 | return $solved; 590 | } 591 | 592 | /** 593 | * Configure this instance. 594 | * 595 | * @param array $config An array with all configuration parameters 596 | */ 597 | private function configure(array $config): void 598 | { 599 | // Process and validate input configuration. 600 | $configuration = new Configuration(); 601 | $processor = new Processor(); 602 | $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($config)]); 603 | } 604 | 605 | /** 606 | * @throws InvalidArgumentException 607 | * @throws BouncerException 608 | * 609 | * @codeCoverageIgnore 610 | */ 611 | private function displayCaptchaWall(string $ip): void 612 | { 613 | $captchaVariables = $this->getCache()->getIpVariables( 614 | Constants::CACHE_TAG_CAPTCHA, 615 | ['resolution_failed', 'inline_image'], 616 | $ip 617 | ); 618 | $body = $this->getCaptchaHtml( 619 | (bool) $captchaVariables['resolution_failed'], 620 | (string) $captchaVariables['inline_image'], 621 | '' 622 | ); 623 | $this->sendResponse($body, 401); 624 | } 625 | 626 | private function getAppSecHeaders(string $ip): array 627 | { 628 | $requestHeaders = $this->getRequestHeaders(); 629 | 630 | return array_merge( 631 | $requestHeaders, 632 | [ 633 | Constants::HEADER_APPSEC_IP => $ip, 634 | Constants::HEADER_APPSEC_URI => $this->getRequestUri(), 635 | Constants::HEADER_APPSEC_HOST => $this->getRequestHost(), 636 | Constants::HEADER_APPSEC_VERB => $this->getHttpMethod(), 637 | Constants::HEADER_APPSEC_API_KEY => $this->remediationEngine->getClient()->getConfig('api_key'), 638 | Constants::HEADER_APPSEC_USER_AGENT => $this->getRequestUserAgent(), 639 | ] 640 | ); 641 | } 642 | 643 | private function getCache(): AbstractCache 644 | { 645 | return $this->getRemediationEngine()->getCacheStorage(); 646 | } 647 | 648 | /** 649 | * @throws BouncerException 650 | * @throws CacheException 651 | */ 652 | private function getRemediation(string $ip): array 653 | { 654 | $remediationData = $this->getRemediationForIp($ip); 655 | $remediation = $remediationData[Constants::REMEDIATION_KEY]; 656 | if ($this->shouldUseAppSec($remediation)) { 657 | $remediationData = $this->getAppSecRemediationForIp($ip); 658 | } 659 | 660 | return $remediationData; 661 | } 662 | 663 | /** 664 | * @return array [[string, string], ...] Returns IP ranges to trust as proxies as an array of comparables ip bounds 665 | */ 666 | private function getTrustForwardedIpBoundsList(): array 667 | { 668 | return $this->getConfig('trust_ip_forward_array') ?? []; 669 | } 670 | 671 | /** 672 | * @throws BouncerException 673 | * 674 | * @codeCoverageIgnore 675 | */ 676 | private function handleBanRemediation(): void 677 | { 678 | $body = $this->getBanHtml(); 679 | $this->sendResponse($body, 403); 680 | } 681 | 682 | /** 683 | * @throws CacheException 684 | * @throws InvalidArgumentException 685 | */ 686 | private function handleBounceExclusion(string $message): bool 687 | { 688 | $this->logger->debug('Will not bounce as exclusion criteria met', [ 689 | 'type' => 'SHOULD_NOT_BOUNCE', 690 | 'message' => $message, 691 | ]); 692 | 693 | return false; 694 | } 695 | 696 | /** 697 | * @throws BouncerException 698 | * @throws CacheException 699 | * @throws InvalidArgumentException 700 | */ 701 | private function handleCaptchaRemediation(string $ip, string $origin): void 702 | { 703 | // Check captcha resolution form 704 | $this->handleCaptchaResolutionForm($ip); 705 | $cachedCaptchaVariables = $this->getCache()->getIpVariables( 706 | Constants::CACHE_TAG_CAPTCHA, 707 | ['has_to_be_resolved'], 708 | $ip 709 | ); 710 | $mustResolve = false; 711 | if (null === $cachedCaptchaVariables['has_to_be_resolved']) { 712 | // Set up the first captcha remediation. 713 | $mustResolve = true; 714 | $this->logger->debug('First captcha resolution', [ 715 | 'ip' => $ip, 716 | ]); 717 | $this->initCaptchaResolution($ip); 718 | } 719 | 720 | // Display captcha page if this is required. 721 | if ($cachedCaptchaVariables['has_to_be_resolved'] || $mustResolve) { 722 | $this->logger->debug('Will display a captcha wall', [ 723 | 'ip' => $ip, 724 | ]); 725 | // Increment captcha origin count 726 | $this->getRemediationEngine()->updateMetricsOriginsCount( 727 | $origin, 728 | Constants::REMEDIATION_CAPTCHA 729 | ); 730 | $this->displayCaptchaWall($ip); 731 | } 732 | // Increment clean origin count 733 | $finalOrigin = AbstractCache::CLEAN_APPSEC === $origin ? AbstractCache::CLEAN_APPSEC : AbstractCache::CLEAN; 734 | $this->getRemediationEngine()->updateMetricsOriginsCount( 735 | $finalOrigin, 736 | Constants::REMEDIATION_BYPASS 737 | ); 738 | $this->logger->info('Captcha wall is not required (already solved)', [ 739 | 'ip' => $ip, 740 | ]); 741 | } 742 | 743 | /** 744 | * @throws CacheException 745 | * @throws InvalidArgumentException 746 | * 747 | * @SuppressWarnings(PHPMD.ElseExpression) 748 | */ 749 | private function handleCaptchaResolutionForm(string $ip): void 750 | { 751 | $cachedCaptchaVariables = $this->getCache()->getIpVariables( 752 | Constants::CACHE_TAG_CAPTCHA, 753 | [ 754 | 'has_to_be_resolved', 755 | 'phrase_to_guess', 756 | 'resolution_redirect', 757 | ], 758 | $ip 759 | ); 760 | if ($this->shouldNotCheckResolution($cachedCaptchaVariables) || $this->refreshCaptcha($ip)) { 761 | return; 762 | } 763 | 764 | // Handle a captcha resolution try 765 | if ( 766 | null !== $this->getPostedVariable('phrase') 767 | && null !== $cachedCaptchaVariables['phrase_to_guess'] 768 | ) { 769 | $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; 770 | if ( 771 | $this->checkCaptcha( 772 | (string) $cachedCaptchaVariables['phrase_to_guess'], 773 | (string) $this->getPostedVariable('phrase'), 774 | $ip 775 | ) 776 | ) { 777 | // User has correctly filled the captcha 778 | $this->getCache()->setIpVariables( 779 | Constants::CACHE_TAG_CAPTCHA, 780 | ['has_to_be_resolved' => false], 781 | $ip, 782 | $duration, 783 | [Constants::CACHE_TAG_CAPTCHA] 784 | ); 785 | $unsetVariables = [ 786 | 'phrase_to_guess', 787 | 'inline_image', 788 | 'resolution_failed', 789 | 'resolution_redirect', 790 | ]; 791 | $this->getCache()->unsetIpVariables( 792 | Constants::CACHE_TAG_CAPTCHA, 793 | $unsetVariables, 794 | $ip, 795 | $duration, 796 | [Constants::CACHE_TAG_CAPTCHA] 797 | ); 798 | $redirect = $cachedCaptchaVariables['resolution_redirect'] ?? '/'; 799 | $this->redirectResponse($redirect); 800 | } else { 801 | // The user failed to resolve the captcha. 802 | $this->getCache()->setIpVariables( 803 | Constants::CACHE_TAG_CAPTCHA, 804 | ['resolution_failed' => true], 805 | $ip, 806 | $duration, 807 | [Constants::CACHE_TAG_CAPTCHA] 808 | ); 809 | } 810 | } 811 | } 812 | 813 | /** 814 | * Handle X-Forwarded-For HTTP header to retrieve the IP to bounce. 815 | * 816 | * @SuppressWarnings(PHPMD.ElseExpression) 817 | */ 818 | private function handleForwardedFor(string $ip, array $configs): string 819 | { 820 | $forwardedIp = null; 821 | if (empty($configs['forced_test_forwarded_ip'])) { 822 | $xForwardedForHeader = $this->getHttpRequestHeader('X-Forwarded-For'); 823 | if (null !== $xForwardedForHeader) { 824 | $ipList = array_map('trim', array_values(array_filter(explode(',', $xForwardedForHeader)))); 825 | $forwardedIp = end($ipList); 826 | } 827 | } elseif (Constants::X_FORWARDED_DISABLED === $configs['forced_test_forwarded_ip']) { 828 | $this->logger->debug('X-Forwarded-for usage is disabled', [ 829 | 'type' => 'DISABLED_X_FORWARDED_FOR_USAGE', 830 | 'original_ip' => $ip, 831 | ]); 832 | } else { 833 | $forwardedIp = (string) $configs['forced_test_forwarded_ip']; 834 | $this->logger->debug('X-Forwarded-for usage is forced', [ 835 | 'type' => 'FORCED_X_FORWARDED_FOR_USAGE', 836 | 'original_ip' => $ip, 837 | 'x_forwarded_for_ip' => $forwardedIp, 838 | ]); 839 | } 840 | 841 | if (is_string($forwardedIp)) { 842 | if ($this->shouldTrustXforwardedFor($ip)) { 843 | $this->logger->debug('Detected IP is allowed for X-Forwarded-for usage', [ 844 | 'type' => 'AUTHORIZED_X_FORWARDED_FOR_USAGE', 845 | 'original_ip' => $ip, 846 | 'x_forwarded_for_ip' => $forwardedIp, 847 | ]); 848 | 849 | return $forwardedIp; 850 | } 851 | $this->logger->warning('Detected IP is not allowed for X-Forwarded-for usage', [ 852 | 'type' => 'NON_AUTHORIZED_X_FORWARDED_FOR_USAGE', 853 | 'original_ip' => $ip, 854 | 'x_forwarded_for_ip' => $forwardedIp, 855 | ]); 856 | } 857 | 858 | return $ip; 859 | } 860 | 861 | /** 862 | * @throws CacheException 863 | * @throws InvalidArgumentException 864 | */ 865 | private function initCaptchaResolution(string $ip): void 866 | { 867 | $captchaCouple = $this->buildCaptchaCouple(); 868 | $referer = $this->getHttpRequestHeader('REFERER'); 869 | $captchaVariables = [ 870 | 'phrase_to_guess' => $captchaCouple['phrase'], 871 | 'inline_image' => $captchaCouple['inlineImage'], 872 | 'has_to_be_resolved' => true, 873 | 'resolution_failed' => false, 874 | 'resolution_redirect' => 'POST' === $this->getHttpMethod() && !empty($referer) 875 | ? $referer : '/', 876 | ]; 877 | $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; 878 | $this->getCache()->setIpVariables( 879 | Constants::CACHE_TAG_CAPTCHA, 880 | $captchaVariables, 881 | $ip, 882 | $duration, 883 | [Constants::CACHE_TAG_CAPTCHA] 884 | ); 885 | } 886 | 887 | /** 888 | * @throws CacheException 889 | * @throws InvalidArgumentException 890 | */ 891 | private function refreshCaptcha(string $ip): bool 892 | { 893 | if (null !== $this->getPostedVariable('refresh') && (int) $this->getPostedVariable('refresh')) { 894 | // Generate new captcha image for the user 895 | $captchaCouple = $this->buildCaptchaCouple(); 896 | $captchaVariables = [ 897 | 'phrase_to_guess' => $captchaCouple['phrase'], 898 | 'inline_image' => $captchaCouple['inlineImage'], 899 | 'resolution_failed' => false, 900 | ]; 901 | $duration = $this->getConfig('captcha_cache_duration') ?? Constants::CACHE_EXPIRATION_FOR_CAPTCHA; 902 | $this->getCache()->setIpVariables( 903 | Constants::CACHE_TAG_CAPTCHA, 904 | $captchaVariables, 905 | $ip, 906 | $duration, 907 | [Constants::CACHE_TAG_CAPTCHA] 908 | ); 909 | 910 | return true; 911 | } 912 | 913 | return false; 914 | } 915 | 916 | /** 917 | * Check if captcha resolution is required or not. 918 | */ 919 | private function shouldNotCheckResolution(array $captchaData): bool 920 | { 921 | $result = false; 922 | if (\in_array($captchaData['has_to_be_resolved'], [null, false])) { 923 | // Check not needed if 'has_to_be_resolved' cached flag has not been saved 924 | $result = true; 925 | } elseif ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) { 926 | // Check not needed if no form captcha form has been filled. 927 | $result = true; 928 | } 929 | 930 | return $result; 931 | } 932 | 933 | private function shouldTrustXforwardedFor(string $ip): bool 934 | { 935 | $parsedAddress = Factory::parseAddressString($ip, 3); 936 | if (null === $parsedAddress) { 937 | $this->logger->warning('IP is invalid', [ 938 | 'type' => 'INVALID_INPUT_IP', 939 | 'ip' => $ip, 940 | ]); 941 | 942 | return false; 943 | } 944 | $comparableAddress = $parsedAddress->getComparableString(); 945 | 946 | foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) { 947 | if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) { 948 | return true; 949 | } 950 | } 951 | 952 | return false; 953 | } 954 | 955 | /** 956 | * Check if AppSec should be used for the current IP. 957 | * 958 | * If remediation is not bypass, it must always return false. 959 | */ 960 | private function shouldUseAppSec(string $remediation): bool 961 | { 962 | $useAppSec = $this->getConfig('use_appsec'); 963 | if (!$useAppSec || Constants::REMEDIATION_BYPASS !== $remediation) { 964 | return false; 965 | } 966 | 967 | $authType = $this->remediationEngine->getClient()->getConfig('auth_type'); 968 | if (Constants::AUTH_TLS === $authType) { 969 | $this->logger->warning('Calling AppSec with a TLS-authenticated bouncer is not supported.', [ 970 | 'type' => 'APPSEC_LAPI_TLS_AUTH_UNSUPPORTED', 971 | 'auth_type_config' => $authType, 972 | 'use_appsec_config' => $useAppSec, 973 | 'message' => 'Please use API key authentication for calling AppSec.', 974 | ]); 975 | 976 | return false; 977 | } 978 | 979 | return true; 980 | } 981 | } 982 | -------------------------------------------------------------------------------- /src/BouncerException.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 54 | $this->addConnectionNodes($rootNode); 55 | $this->addDebugNodes($rootNode); 56 | $this->addBouncerNodes($rootNode); 57 | $this->addCacheNodes($rootNode); 58 | $this->addTemplateNodes($rootNode); 59 | 60 | return $treeBuilder; 61 | } 62 | 63 | /** 64 | * Bouncer settings. 65 | * 66 | * @param NodeDefinition|ArrayNodeDefinition $rootNode 67 | * 68 | * @return void 69 | */ 70 | private function addBouncerNodes($rootNode) 71 | { 72 | $rootNode->children() 73 | ->arrayNode('trust_ip_forward_array') 74 | ->arrayPrototype() 75 | ->scalarPrototype()->end() 76 | ->end() 77 | ->end() 78 | ->arrayNode('excluded_uris') 79 | ->scalarPrototype()->end() 80 | ->end() 81 | ->booleanNode('use_appsec')->defaultValue(false)->end() 82 | ->end(); 83 | } 84 | 85 | /** 86 | * Cache settings. 87 | * 88 | * @param NodeDefinition|ArrayNodeDefinition $rootNode 89 | * 90 | * @return void 91 | * 92 | * @throws \InvalidArgumentException 93 | */ 94 | private function addCacheNodes($rootNode) 95 | { 96 | $rootNode->children() 97 | ->enumNode('cache_system') 98 | ->values( 99 | [ 100 | Constants::CACHE_SYSTEM_PHPFS, 101 | Constants::CACHE_SYSTEM_REDIS, 102 | Constants::CACHE_SYSTEM_MEMCACHED, 103 | ] 104 | ) 105 | ->defaultValue(Constants::CACHE_SYSTEM_PHPFS) 106 | ->end() 107 | ->integerNode('captcha_cache_duration') 108 | ->min(1)->defaultValue(Constants::CACHE_EXPIRATION_FOR_CAPTCHA) 109 | ->end() 110 | ->end(); 111 | } 112 | 113 | /** 114 | * LAPI connection settings. 115 | * 116 | * @param NodeDefinition|ArrayNodeDefinition $rootNode 117 | * 118 | * @return void 119 | */ 120 | private function addConnectionNodes($rootNode) 121 | { 122 | $rootNode->children() 123 | ->booleanNode('use_curl')->defaultValue(false)->end() 124 | ->end(); 125 | } 126 | 127 | /** 128 | * Debug settings. 129 | * 130 | * @param NodeDefinition|ArrayNodeDefinition $rootNode 131 | * 132 | * @return void 133 | */ 134 | private function addDebugNodes($rootNode) 135 | { 136 | $rootNode->children() 137 | ->scalarNode('forced_test_ip')->defaultValue('')->end() 138 | ->scalarNode('forced_test_forwarded_ip')->defaultValue('')->end() 139 | ->booleanNode('debug_mode')->defaultValue(false)->end() 140 | ->booleanNode('disable_prod_log')->defaultValue(false)->end() 141 | ->scalarNode('log_directory_path')->end() 142 | ->booleanNode('display_errors')->defaultValue(false)->end() 143 | ->end(); 144 | } 145 | 146 | /** 147 | * @return void 148 | */ 149 | private function addTemplateNodes($rootNode) 150 | { 151 | $defaultSubtitle = 'This page is protected against cyber attacks and your IP has been banned by our system.'; 152 | $rootNode->children() 153 | ->arrayNode('color')->addDefaultsIfNotSet() 154 | ->children() 155 | ->arrayNode('text')->addDefaultsIfNotSet() 156 | ->children() 157 | ->scalarNode('primary')->defaultValue('black')->end() 158 | ->scalarNode('secondary')->defaultValue('#AAA')->end() 159 | ->scalarNode('button')->defaultValue('white')->end() 160 | ->scalarNode('error_message')->defaultValue('#b90000')->end() 161 | ->end() 162 | ->end() 163 | ->arrayNode('background')->addDefaultsIfNotSet() 164 | ->children() 165 | ->scalarNode('page')->defaultValue('#eee')->end() 166 | ->scalarNode('container')->defaultValue('white')->end() 167 | ->scalarNode('button')->defaultValue('#626365')->end() 168 | ->scalarNode('button_hover')->defaultValue('#333')->end() 169 | ->end() 170 | ->end() 171 | ->end() 172 | ->end() 173 | ->arrayNode('text')->addDefaultsIfNotSet() 174 | ->children() 175 | ->arrayNode('captcha_wall')->addDefaultsIfNotSet() 176 | ->children() 177 | ->scalarNode('tab_title')->defaultValue('Oops..')->end() 178 | ->scalarNode('title')->defaultValue('Hmm, sorry but...')->end() 179 | ->scalarNode('subtitle')->defaultValue('Please complete the security check.')->end() 180 | ->scalarNode('refresh_image_link')->defaultValue('refresh image')->end() 181 | ->scalarNode('captcha_placeholder')->defaultValue('Type here...')->end() 182 | ->scalarNode('send_button')->defaultValue('CONTINUE')->end() 183 | ->scalarNode('error_message')->defaultValue('Please try again.')->end() 184 | ->scalarNode('footer')->defaultValue('')->end() 185 | ->end() 186 | ->end() 187 | ->arrayNode('ban_wall')->addDefaultsIfNotSet() 188 | ->children() 189 | ->scalarNode('tab_title')->defaultValue('Oops..')->end() 190 | ->scalarNode('title')->defaultValue('🤭 Oh!')->end() 191 | ->scalarNode('subtitle')->defaultValue($defaultSubtitle)->end() 192 | ->scalarNode('footer')->defaultValue('')->end() 193 | ->end() 194 | ->end() 195 | ->end() 196 | ->end() 197 | ->booleanNode('hide_mentions')->defaultValue(false)->end() 198 | ->scalarNode('custom_css')->defaultValue('')->end() 199 | ->end(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Constants.php: -------------------------------------------------------------------------------- 1 | getMultipartRawBody($contentType, $sizeThreshold, $postData, $filesData); 45 | } 46 | 47 | return $this->getRawInput($sizeThreshold, $stream); 48 | } 49 | 50 | private function appendFileData( 51 | array $fileArray, 52 | int $index, 53 | string $fileKey, 54 | string $boundary, 55 | int $threshold, 56 | int &$currentSize 57 | ): string { 58 | $fileName = is_array($fileArray['name']) ? $fileArray['name'][$index] : $fileArray['name']; 59 | $fileTmpName = is_array($fileArray['tmp_name']) ? $fileArray['tmp_name'][$index] : $fileArray['tmp_name']; 60 | $fileType = is_array($fileArray['type']) ? $fileArray['type'][$index] : $fileArray['type']; 61 | 62 | $headerPart = '--' . $boundary . "\r\n"; 63 | $headerPart .= "Content-Disposition: form-data; name=\"$fileKey\"; filename=\"$fileName\"\r\n"; 64 | $headerPart .= "Content-Type: $fileType\r\n\r\n"; 65 | 66 | $currentSize += strlen($headerPart); 67 | if ($currentSize >= $threshold) { 68 | return substr($headerPart, 0, $threshold - ($currentSize - strlen($headerPart))); 69 | } 70 | 71 | $remainingSize = $threshold - $currentSize; 72 | $fileStream = fopen($fileTmpName, 'rb'); 73 | $fileContent = $this->readStream($fileStream, $remainingSize); 74 | // Add 2 bytes for the \r\n at the end of the file content 75 | $currentSize += strlen($fileContent) + 2; 76 | 77 | return $headerPart . $fileContent . "\r\n"; 78 | } 79 | 80 | private function buildFormData(string $boundary, string $key, string $value): string 81 | { 82 | return '--' . $boundary . "\r\n" . 83 | "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" . 84 | "$value\r\n"; 85 | } 86 | 87 | /** 88 | * Extract the boundary from the Content-Type. 89 | * 90 | * Regex breakdown: 91 | * /boundary="?([^;"]+)"?/i 92 | * 93 | * - boundary= : Matches the literal string 'boundary=' which indicates the start of the boundary parameter. 94 | * - "? : Matches an optional double quote that may surround the boundary value. 95 | * - ([^;"]+) : Captures one or more characters that are not a semicolon (;) or a double quote (") into a group. 96 | * This ensures the boundary is extracted accurately, stopping at a semicolon if present, 97 | * and avoiding the inclusion of quotes in the captured value. 98 | * - "? : Matches an optional closing double quote (if the boundary is quoted). 99 | * - i : Case-insensitive flag to handle 'boundary=' in any case (e.g., 'Boundary=' or 'BOUNDARY='). 100 | * 101 | * @throws BouncerException 102 | */ 103 | private function extractBoundary(string $contentType): string 104 | { 105 | if (preg_match('/boundary="?([^;"]+)"?/i', $contentType, $matches)) { 106 | return trim($matches[1]); 107 | } 108 | throw new BouncerException("Failed to extract boundary from Content-Type: ($contentType)"); 109 | } 110 | 111 | /** 112 | * Return the raw body for multipart requests. 113 | * This method will read the raw body up to the specified threshold. 114 | * If the body is too large, it will return a truncated version of the body up to the threshold. 115 | * 116 | * @throws BouncerException 117 | */ 118 | private function getMultipartRawBody( 119 | string $contentType, 120 | int $threshold, 121 | array $postData, 122 | array $filesData 123 | ): string { 124 | try { 125 | $boundary = $this->extractBoundary($contentType); 126 | // Instead of concatenating strings, we will use an array to store the parts 127 | // and then join them with implode at the end to avoid performance issues. 128 | $parts = []; 129 | $currentSize = 0; 130 | 131 | foreach ($postData as $key => $value) { 132 | $formData = $this->buildFormData($boundary, $key, $value); 133 | $currentSize += strlen($formData); 134 | if ($currentSize >= $threshold) { 135 | return substr(implode('', $parts) . $formData, 0, $threshold); 136 | } 137 | 138 | $parts[] = $formData; 139 | } 140 | 141 | foreach ($filesData as $fileKey => $fileArray) { 142 | $fileNames = is_array($fileArray['name']) ? $fileArray['name'] : [$fileArray['name']]; 143 | foreach ($fileNames as $index => $fileName) { 144 | $remainingSize = $threshold - $currentSize; 145 | $fileData = 146 | $this->appendFileData($fileArray, $index, $fileKey, $boundary, $remainingSize, $currentSize); 147 | if ($currentSize >= $threshold) { 148 | return substr(implode('', $parts) . $fileData, 0, $threshold); 149 | } 150 | $parts[] = $fileData; 151 | } 152 | } 153 | 154 | $endBoundary = '--' . $boundary . "--\r\n"; 155 | $currentSize += strlen($endBoundary); 156 | 157 | if ($currentSize >= $threshold) { 158 | return substr(implode('', $parts) . $endBoundary, 0, $threshold); 159 | } 160 | 161 | $parts[] = $endBoundary; 162 | 163 | return implode('', $parts); 164 | } catch (\Throwable $e) { 165 | throw new BouncerException('Failed to read multipart raw body: ' . $e->getMessage()); 166 | } 167 | } 168 | 169 | private function getRawInput(int $threshold, $stream): string 170 | { 171 | return $this->readStream($stream, $threshold); 172 | } 173 | 174 | /** 175 | * Read the stream up to the specified threshold. 176 | * 177 | * @param resource $stream The stream to read 178 | * @param int $threshold The maximum number of bytes to read 179 | * 180 | * @throws BouncerException 181 | */ 182 | private function readStream($stream, int $threshold): string 183 | { 184 | if (!is_resource($stream)) { 185 | throw new BouncerException('Stream is not a valid resource'); 186 | } 187 | $buffer = ''; 188 | $chunkSize = 8192; 189 | $bytesRead = 0; 190 | // We make sure there won't be infinite loop 191 | $maxLoops = (int) ceil($threshold / $chunkSize); 192 | $loopCount = -1; 193 | 194 | try { 195 | while (!feof($stream) && $bytesRead < $threshold) { 196 | ++$loopCount; 197 | if ($loopCount >= $maxLoops) { 198 | throw new BouncerException("Too many loops ($loopCount) while reading stream"); 199 | } 200 | $remainingSize = $threshold - $bytesRead; 201 | $readLength = min($chunkSize, $remainingSize); 202 | 203 | $data = fread($stream, $readLength); 204 | if (false === $data) { 205 | throw new BouncerException('Failed to read chunk from stream'); 206 | } 207 | 208 | $buffer .= $data; 209 | $bytesRead += strlen($data); 210 | 211 | if ($bytesRead >= $threshold) { 212 | break; 213 | } 214 | } 215 | 216 | return $buffer; 217 | } catch (\Throwable $e) { 218 | throw new BouncerException('Failed to read stream: ' . $e->getMessage()); 219 | } finally { 220 | fclose($stream); 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Template.php: -------------------------------------------------------------------------------- 1 | template = $env->load($path); 39 | } 40 | 41 | public function render(array $config = []): string 42 | { 43 | return $this->template->render(['config' => $config]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/templates/ban.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include('partials/head.html.twig') with {'tab_title': config.text.ban_wall.tab_title} %} 5 | 6 | 7 |
8 |
9 |

{{ config.text.ban_wall.title | e }}

10 |

{{ config.text.ban_wall.subtitle }}

11 | {% if config.text.ban_wall.footer is not empty %} 12 | 13 | {% endif %} 14 | {% if config.hide_mentions is empty %} 15 | {% include('partials/mentions.html.twig') %} 16 | {% endif %} 17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /src/templates/captcha.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include('partials/head.html.twig') with {'tab_title': config.text.captcha_wall.tab_title} %} 5 | {% include('partials/captcha-js.html.twig') %} 6 | 7 | 8 |
9 |
10 |

11 | 16 | {{ config.text.captcha_wall.title |e }} 17 |

18 |

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

19 | 20 | captcha 21 |

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

23 | 24 |
25 | 26 | 28 | 29 | 30 | {% if config.error is not empty %} 31 |

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

32 | {% endif %} 33 | 34 |
35 | {% if config.text.ban_wall.footer is not empty %} 36 | 37 | {% endif %} 38 | {% if config.hide_mentions is empty %} 39 | {% include('partials/mentions.html.twig') %} 40 | {% endif %} 41 |
42 |
43 | 44 | -------------------------------------------------------------------------------- /src/templates/partials/bottom.html.twig: -------------------------------------------------------------------------------- 1 | {% if config.text.ban_wall.footer is not empty %} 2 | 3 | {% endif %} -------------------------------------------------------------------------------- /src/templates/partials/captcha-js.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /src/templates/partials/head.html.twig: -------------------------------------------------------------------------------- 1 | {{ tab_title | e }} 2 | 3 | -------------------------------------------------------------------------------- /src/templates/partials/mentions.html.twig: -------------------------------------------------------------------------------- 1 |

This security check has been powered by 2 | 26 | CrowdSec 27 |

-------------------------------------------------------------------------------- /tests/Integration/GeolocationTest.php: -------------------------------------------------------------------------------- 1 | useTls = (string) getenv('BOUNCER_TLS_PATH'); 63 | $this->useCurl = (bool) getenv('USE_CURL'); 64 | $this->logger = TestHelpers::createLogger(); 65 | 66 | $bouncerConfigs = [ 67 | 'auth_type' => $this->useTls ? \CrowdSec\LapiClient\Constants::AUTH_TLS : Constants::AUTH_KEY, 68 | 'api_key' => getenv('BOUNCER_KEY'), 69 | 'api_url' => getenv('LAPI_URL'), 70 | 'use_curl' => $this->useCurl, 71 | 'user_agent_suffix' => 'testphpbouncer', 72 | ]; 73 | if ($this->useTls) { 74 | $this->addTlsConfig($bouncerConfigs, $this->useTls); 75 | } 76 | 77 | $this->configs = $bouncerConfigs; 78 | $this->watcherClient = new WatcherClient($this->configs); 79 | // Delete all decisions 80 | $this->watcherClient->deleteAllDecisions(); 81 | } 82 | 83 | public function maxmindConfigProvider(): array 84 | { 85 | return TestHelpers::maxmindConfigProvider(); 86 | } 87 | 88 | private function handleMaxMindConfig(array $maxmindConfig): array 89 | { 90 | // Check if MaxMind database exist 91 | if (!file_exists($maxmindConfig['database_path'])) { 92 | $this->fail('There must be a MaxMind Database here: ' . $maxmindConfig['database_path']); 93 | } 94 | 95 | return [ 96 | 'cache_duration' => 0, 97 | 'enabled' => true, 98 | 'type' => 'maxmind', 99 | 'maxmind' => [ 100 | 'database_type' => $maxmindConfig['database_type'], 101 | 'database_path' => $maxmindConfig['database_path'], 102 | ], 103 | ]; 104 | } 105 | 106 | /** 107 | * @dataProvider maxmindConfigProvider 108 | * 109 | * @throws \Symfony\Component\Cache\Exception\CacheException 110 | * @throws \Psr\Cache\InvalidArgumentException 111 | */ 112 | public function testCanVerifyIpAndCountryWithMaxmindInLiveMode(array $maxmindConfig): void 113 | { 114 | // Init context 115 | $this->watcherClient->setInitialState(); 116 | 117 | // Init bouncer 118 | $geolocationConfig = $this->handleMaxMindConfig($maxmindConfig); 119 | $bouncerConfigs = [ 120 | 'api_key' => TestHelpers::getBouncerKey(), 121 | 'api_url' => TestHelpers::getLapiUrl(), 122 | 'geolocation' => $geolocationConfig, 123 | 'use_curl' => $this->useCurl, 124 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 125 | 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR, 126 | 'stream_mode' => false, 127 | ]; 128 | 129 | $client = new BouncerClient($bouncerConfigs); 130 | $cache = new PhpFiles($bouncerConfigs); 131 | $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache); 132 | 133 | $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); 134 | 135 | $bouncer->clearCache(); 136 | 137 | $this->assertEquals( 138 | 'captcha', 139 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 140 | 'Get decisions for a clean IP but bad country (captcha)' 141 | ); 142 | 143 | $this->assertEquals( 144 | 'bypass', 145 | $bouncer->getRemediationForIp(TestHelpers::IP_FRANCE)['remediation'], 146 | 'Get decisions for a clean IP and clean country' 147 | ); 148 | 149 | // Disable Geolocation feature 150 | $geolocationConfig['enabled'] = false; 151 | $bouncerConfigs['geolocation'] = $geolocationConfig; 152 | $client = new BouncerClient($bouncerConfigs); 153 | $cache = new PhpFiles($bouncerConfigs); 154 | $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache); 155 | 156 | $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); 157 | $bouncer->clearCache(); 158 | 159 | $this->assertEquals( 160 | 'bypass', 161 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 162 | 'Get decisions for a clean IP and bad country but with geolocation disabled' 163 | ); 164 | 165 | // Enable again geolocation and change testing conditions 166 | $this->watcherClient->setSecondState(); 167 | $geolocationConfig['enabled'] = true; 168 | $bouncerConfigs['geolocation'] = $geolocationConfig; 169 | $client = new BouncerClient($bouncerConfigs); 170 | $cache = new PhpFiles($bouncerConfigs); 171 | $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache); 172 | $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); 173 | $bouncer->clearCache(); 174 | 175 | $this->assertEquals( 176 | 'ban', 177 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 178 | 'Get decisions for a bad IP (ban) and bad country (captcha)' 179 | ); 180 | 181 | $this->assertEquals( 182 | 'ban', 183 | $bouncer->getRemediationForIp(TestHelpers::IP_FRANCE)['remediation'], 184 | 'Get decisions for a bad IP (ban) and clean country' 185 | ); 186 | } 187 | 188 | /** 189 | * @group integration 190 | * 191 | * @dataProvider maxmindConfigProvider 192 | * 193 | * @throws \Symfony\Component\Cache\Exception\CacheException|\Psr\Cache\InvalidArgumentException 194 | */ 195 | public function testCanVerifyIpAndCountryWithMaxmindInStreamMode(array $maxmindConfig): void 196 | { 197 | // Init context 198 | $this->watcherClient->setInitialState(); 199 | // Init bouncer 200 | $geolocationConfig = $this->handleMaxMindConfig($maxmindConfig); 201 | $bouncerConfigs = [ 202 | 'api_key' => TestHelpers::getBouncerKey(), 203 | 'api_url' => TestHelpers::getLapiUrl(), 204 | 'stream_mode' => true, 205 | 'geolocation' => $geolocationConfig, 206 | 'use_curl' => $this->useCurl, 207 | 'cache_system' => Constants::CACHE_SYSTEM_PHPFS, 208 | 'fs_cache_path' => TestHelpers::PHP_FILES_CACHE_ADAPTER_DIR, 209 | ]; 210 | 211 | $client = new BouncerClient($bouncerConfigs); 212 | $cache = new PhpFiles($bouncerConfigs); 213 | $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache); 214 | 215 | $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); 216 | $cacheAdapter = $bouncer->getRemediationEngine()->getCacheStorage(); 217 | $cacheAdapter->clear(); 218 | 219 | // Warm BlockList cache up 220 | $bouncer->refreshBlocklistCache(); 221 | 222 | $this->logger->debug('', ['message' => 'Refresh the cache just after the warm up. Nothing should append.']); 223 | $bouncer->refreshBlocklistCache(); 224 | 225 | $this->assertEquals( 226 | 'captcha', 227 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 228 | 'Should captcha a clean IP coming from a bad country (captcha)' 229 | ); 230 | 231 | // Add and remove decision 232 | $this->watcherClient->setSecondState(); 233 | 234 | $this->assertEquals( 235 | 'captcha', 236 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 237 | 'Should still captcha a bad IP (ban) coming from a bad country (captcha) as cache has not been refreshed' 238 | ); 239 | 240 | // Pull updates 241 | $bouncer->refreshBlocklistCache(); 242 | 243 | $this->assertEquals( 244 | 'ban', 245 | $bouncer->getRemediationForIp(TestHelpers::IP_JAPAN)['remediation'], 246 | 'The new decision should now be added, so the previously captcha IP should now be ban' 247 | ); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /tests/Integration/TestHelpers.php: -------------------------------------------------------------------------------- 1 | setFormatter(new LineFormatter("%datetime%|%level%|%context%\n")); 35 | $log->pushHandler($handler); 36 | 37 | return $log; 38 | } 39 | 40 | /** 41 | * @throws ErrorException 42 | * @throws CacheException 43 | */ 44 | public static function cacheAdapterConfigProvider(): array 45 | { 46 | return [ 47 | 'PhpFilesAdapter' => [Constants::CACHE_SYSTEM_PHPFS, 'PhpFilesAdapter'], 48 | 'RedisAdapter' => [Constants::CACHE_SYSTEM_REDIS, 'RedisAdapter'], 49 | 'MemcachedAdapter' => [Constants::CACHE_SYSTEM_MEMCACHED, 'MemcachedAdapter'], 50 | ]; 51 | } 52 | 53 | public static function maxmindConfigProvider(): array 54 | { 55 | return [ 56 | 'country database' => [[ 57 | 'database_type' => 'country', 58 | 'database_path' => __DIR__ . '/../GeoLite2-Country.mmdb', 59 | ]], 60 | 'city database' => [[ 61 | 'database_type' => 'city', 62 | 'database_path' => __DIR__ . '/../GeoLite2-City.mmdb', 63 | ]], 64 | ]; 65 | } 66 | 67 | public static function getLapiUrl(): string 68 | { 69 | return getenv('LAPI_URL'); 70 | } 71 | 72 | public static function getAppSecUrl(): string 73 | { 74 | return getenv('APPSEC_URL'); 75 | } 76 | 77 | public static function getBouncerKey(): string 78 | { 79 | if ($bouncerKey = getenv('BOUNCER_KEY')) { 80 | return $bouncerKey; 81 | } 82 | 83 | return ''; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /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 | parent::__construct($this->configs); 43 | } 44 | 45 | /** 46 | * Make a request. 47 | * 48 | * @throws ClientException 49 | */ 50 | private function manageRequest( 51 | string $method, 52 | string $endpoint, 53 | array $parameters = [] 54 | ): array { 55 | $this->logger->debug('', [ 56 | 'type' => 'WATCHER_CLIENT_REQUEST', 57 | 'method' => $method, 58 | 'endpoint' => $endpoint, 59 | 'parameters' => $parameters, 60 | ]); 61 | 62 | return $this->request($method, $endpoint, $parameters, $this->headers); 63 | } 64 | 65 | /** Set the initial watcher state */ 66 | public function setInitialState(): void 67 | { 68 | $this->deleteAllDecisions(); 69 | $now = new \DateTime(); 70 | $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, 'captcha'); 71 | $this->addDecision($now, '24h', self::HOURS24, TestHelpers::BAD_IP . '/' . TestHelpers::IP_RANGE, 'ban'); 72 | $this->addDecision($now, '24h', '+24 hours', TestHelpers::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); 73 | usleep(500 * 1000); // 500ms 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 | usleep(500 * 1000); 94 | } 95 | 96 | public function setSimpleDecision(string $type = 'ban'): void 97 | { 98 | $this->deleteAllDecisions(); 99 | $now = new \DateTime(); 100 | $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, $type); 101 | } 102 | 103 | /** 104 | * Ensure we retrieved a JWT to connect the API. 105 | */ 106 | private function ensureLogin(): void 107 | { 108 | if (!$this->token) { 109 | $data = [ 110 | 'scenarios' => [], 111 | 'machine_id' => self::WATCHER_LOGIN, 112 | 'password' => self::WATCHER_PASSWORD, 113 | ]; 114 | if (getenv('AGENT_TLS_PATH')) { 115 | unset($data['password'], $data['machine_id']); 116 | } 117 | 118 | $credentials = $this->manageRequest( 119 | 'POST', 120 | self::WATCHER_LOGIN_ENDPOINT, 121 | $data 122 | ); 123 | 124 | $this->token = $credentials['token']; 125 | $this->headers['Authorization'] = 'Bearer ' . $this->token; 126 | } 127 | } 128 | 129 | public function deleteAllDecisions(): void 130 | { 131 | // Delete all existing decisions. 132 | $this->ensureLogin(); 133 | 134 | $this->manageRequest( 135 | 'DELETE', 136 | self::WATCHER_DECISIONS_ENDPOINT, 137 | [] 138 | ); 139 | } 140 | 141 | protected function getFinalScope($scope, $value) 142 | { 143 | $scope = (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : 144 | $scope; 145 | 146 | /** 147 | * Must use capital first letter as the crowdsec agent seems to query with first capital letter 148 | * during getStreamDecisions. 149 | * 150 | * @see https://github.com/crowdsecurity/crowdsec/blob/ae6bf3949578a5f3aa8ec415e452f15b404ba5af/pkg/database/decisions.go#L56 151 | */ 152 | return ucfirst($scope); 153 | } 154 | 155 | public function addDecision( 156 | \DateTime $now, 157 | string $durationString, 158 | string $dateTimeDurationString, 159 | string $value, 160 | string $type, 161 | string $scope = Constants::SCOPE_IP 162 | ) { 163 | $stopAt = (clone $now)->modify($dateTimeDurationString)->format('Y-m-d\TH:i:s.000\Z'); 164 | $startAt = $now->format('Y-m-d\TH:i:s.000\Z'); 165 | 166 | $body = [ 167 | 'capacity' => 0, 168 | 'decisions' => [ 169 | [ 170 | 'duration' => $durationString, 171 | 'origin' => 'cscli', 172 | 'scenario' => $type . ' for scope/value (' . $scope . '/' . $value . ') for ' 173 | . $durationString . ' for PHPUnit tests', 174 | 'scope' => $this->getFinalScope($scope, $value), 175 | 'type' => $type, 176 | 'value' => $value, 177 | ], 178 | ], 179 | 'events' => [ 180 | ], 181 | 'events_count' => 1, 182 | 'labels' => null, 183 | 'leakspeed' => '0', 184 | 'message' => 'setup for PHPUnit tests', 185 | 'scenario' => 'setup for PHPUnit tests', 186 | 'scenario_hash' => '', 187 | 'scenario_version' => '', 188 | 'simulated' => false, 189 | 'source' => [ 190 | 'scope' => $this->getFinalScope($scope, $value), 191 | 'value' => $value, 192 | ], 193 | 'start_at' => $startAt, 194 | 'stop_at' => $stopAt, 195 | ]; 196 | 197 | $result = $this->manageRequest( 198 | 'POST', 199 | self::WATCHER_ALERT_ENDPOINT, 200 | [$body] 201 | ); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /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/FailingStreamWrapper.php: -------------------------------------------------------------------------------- 1 | root->url() . '/tmp-test.txt', 'THIS IS A TEST FILE'); 40 | 41 | $serverData = ['CONTENT_TYPE' => 'multipart/form-data; badstring=----WebKitFormBoundary']; 42 | $postData = ['key' => 'value']; 43 | $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/tmp-test.txt', 'type' => 'text/plain']]; 44 | $maxBodySize = 1; 45 | 46 | $error = ''; 47 | 48 | try { 49 | $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, $postData, $filesData); 50 | } catch (CrowdSecBouncer\BouncerException $e) { 51 | $error = $e->getMessage(); 52 | } 53 | $this->assertEquals('Failed to read multipart raw body: Failed to extract boundary from Content-Type: (multipart/form-data; badstring=----WebKitFormBoundary)', $error); 54 | } 55 | 56 | public function testBuildRawBodyFromSuperglobalsWithEmptyContentTypeReturnsRawInput() 57 | { 58 | $serverData = []; 59 | $streamType = 'php://memory'; 60 | $inputStream = fopen($streamType, 'r+'); 61 | fwrite($inputStream, '{"key": "value"}'); 62 | rewind($inputStream); 63 | $maxBodySize = 1; 64 | 65 | $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); 66 | 67 | $this->assertEquals('{"key": "value"}', $result); 68 | } 69 | 70 | public function testBuildRawBodyFromSuperglobalsWithLargeBodyTruncatesBody() 71 | { 72 | $serverData = ['CONTENT_TYPE' => 'application/json']; 73 | $streamType = 'php://memory'; 74 | $inputStream = fopen($streamType, 'r+'); 75 | fwrite($inputStream, str_repeat('a', 2048)); 76 | rewind($inputStream); 77 | $maxBodySize = 1; 78 | 79 | $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); 80 | 81 | $this->assertEquals(str_repeat('a', 1025), $result); 82 | } 83 | 84 | public function testBuildRawBodyFromSuperglobalsWithMultipartContentTypeReturnsMultipartRawBody() 85 | { 86 | file_put_contents($this->root->url() . '/tmp-test.txt', 'THIS IS A TEST FILE'); 87 | 88 | $serverData = ['CONTENT_TYPE' => 'multipart/form-data; boundary=----WebKitFormBoundary; charset=UTF-8']; 89 | $postData = ['key' => 'value']; 90 | $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/tmp-test.txt', 'type' => 'text/plain']]; 91 | $maxBodySize = 1; 92 | 93 | $result = $this->buildRawBodyFromSuperglobals($maxBodySize, null, $serverData, $postData, $filesData); 94 | 95 | $this->assertStringContainsString('Content-Disposition: form-data; name="key"', $result); 96 | $this->assertStringContainsString('Content-Disposition: form-data; name="file"; filename="test.txt"', $result); 97 | 98 | $this->assertEquals(248, strlen($result)); 99 | $this->assertStringContainsString('THIS IS A TEST FILE', $result); 100 | } 101 | 102 | public function testBuildRawBodyFromSuperglobalsWithNoStreamShouldThrowException() 103 | { 104 | $serverData = ['CONTENT_TYPE' => 'application/json']; 105 | $streamType = 'php://temp'; 106 | $inputStream = fopen($streamType, 'r+'); 107 | fwrite($inputStream, '{"key": "value"}'); 108 | // We are closing the stream so it becomes unavailable 109 | fclose($inputStream); 110 | $maxBodySize = 15; 111 | 112 | $error = ''; 113 | try { 114 | $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); 115 | } catch (CrowdSecBouncer\BouncerException $e) { 116 | $error = $e->getMessage(); 117 | } 118 | 119 | $this->assertEquals('Stream is not a valid resource', $error); 120 | } 121 | 122 | public function testBuildRawBodyFromSuperglobalsWithNonMultipartContentTypeReturnsRawInput() 123 | { 124 | $serverData = ['CONTENT_TYPE' => 'application/json']; 125 | $streamType = 'php://memory'; 126 | $inputStream = fopen($streamType, 'r+'); 127 | fwrite($inputStream, '{"key": "value"}'); 128 | rewind($inputStream); 129 | $maxBodySize = 15; 130 | 131 | $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); 132 | 133 | $this->assertEquals('{"key": "value"}', $result); 134 | } 135 | 136 | public function testGetMultipartRawBodyWithLargeFileDataShouldThrowException() 137 | { 138 | $contentType = 'multipart/form-data; BOUNDARY=----WebKitFormBoundary'; 139 | $postData = []; 140 | $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; 141 | // We don't create the file so it will throw an exception 142 | 143 | $error = ''; 144 | try { 145 | $this->getMultipartRawBody($contentType, 1025, $postData, $filesData); 146 | } catch (CrowdSecBouncer\BouncerException $e) { 147 | $error = $e->getMessage(); 148 | } 149 | 150 | $this->assertStringContainsString('Failed to read multipart raw body', $error); 151 | $this->assertStringContainsString('fopen(vfs://tmp/phpYzdqkD)', $error); 152 | } 153 | 154 | public function testGetMultipartRawBodyWithLargeFileDataTruncatesBody() 155 | { 156 | $contentType = 'multipart/form-data; bOuNdary="----WebKitFormBoundary"'; 157 | $postData = []; 158 | $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; 159 | file_put_contents($this->root->url() . '/phpYzdqkD', 'THIS_IS_THE_CONTENT' . str_repeat('a', 2048)); 160 | $threshold = 1025; 161 | 162 | $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); 163 | 164 | $this->assertEquals(1025, strlen($result)); 165 | $this->assertStringContainsString('THIS_IS_THE_CONTENT', $result); 166 | } 167 | 168 | public function testGetMultipartRawBodyWithLargeFileDataTruncatesBodyEnBoundary() 169 | { 170 | $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; 171 | $postData = []; 172 | $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; 173 | file_put_contents($this->root->url() . '/phpYzdqkD', str_repeat('a', 2045)); 174 | // Total size without adding boundary is 2167 175 | $threshold = 2168; 176 | 177 | $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); 178 | 179 | $this->assertEquals(2168, strlen($result)); 180 | } 181 | 182 | /** 183 | * @group unit 184 | */ 185 | public function testGetMultipartRawBodyWithLargeFileNameTruncatesBody() 186 | { 187 | $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; 188 | $postData = []; 189 | $filesData = ['file' => ['name' => str_repeat('a', 2048) . '.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; 190 | file_put_contents($this->root->url() . '/phpYzdqkD', 'THIS_IS_THE_CONTENT'); 191 | $threshold = 1025; 192 | 193 | $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); 194 | 195 | $this->assertEquals(1025, strlen($result)); 196 | $this->assertStringNotContainsString('THIS_IS_THE_CONTENT', $result); 197 | } 198 | 199 | public function testGetMultipartRawBodyWithLargePostDataTruncatesBody() 200 | { 201 | $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; 202 | $postData = ['key' => str_repeat('a', 2048)]; 203 | $filesData = []; 204 | $threshold = 1025; 205 | 206 | $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); 207 | 208 | $this->assertEquals(1025, strlen($result)); 209 | $this->assertStringContainsString('Content-Disposition: form-data; name="key"', $result); 210 | $this->assertStringContainsString(str_repeat('a', 953), $result); 211 | } 212 | 213 | /** 214 | * @group up-to-php74 215 | * Before PHP 7.4, fread can fail without returning false, leading to an infinite loop. 216 | * 217 | * @see https://bugs.php.net/bug.php?id=79965 218 | */ 219 | public function testReadStreamWithFreadFailureShouldThrowException() 220 | { 221 | // Register custom stream wrapper that fails on fread 222 | stream_wrapper_register('failing', FailingStreamWrapper::class); 223 | FailingStreamWrapper::$eofResult = false; 224 | FailingStreamWrapper::$readResult = false; 225 | 226 | // Open a stream using the failing stream wrapper 227 | $mockStream = fopen('failing://test', 'r+'); 228 | 229 | // Set the threshold (can be any number) 230 | $threshold = 100; 231 | 232 | $error = ''; 233 | try { 234 | $this->readStream($mockStream, $threshold); 235 | } catch (CrowdSecBouncer\BouncerException $e) { 236 | $error = $e->getMessage(); 237 | } 238 | 239 | // Assert that the correct exception message was thrown 240 | $this->assertStringStartsWith('Failed to read stream: Failed to read chunk from stream', $error); 241 | 242 | // Clean up the custom stream wrapper 243 | stream_wrapper_unregister('failing'); 244 | } 245 | 246 | /** 247 | * @group infinite-loop 248 | */ 249 | public function testReadStreamShouldNotInfiniteLoop() 250 | { 251 | // Register custom stream wrapper that will read forever 252 | stream_wrapper_register('failing', FailingStreamWrapper::class); 253 | FailingStreamWrapper::$eofResult = false; 254 | FailingStreamWrapper::$readResult = ''; 255 | 256 | // Open a stream using the failing stream wrapper 257 | $mockStream = fopen('failing://test', 'r+'); 258 | 259 | // Set the threshold (can be any number) 260 | $threshold = 8192 * 3; 261 | 262 | $error = ''; 263 | try { 264 | $this->readStream($mockStream, $threshold); 265 | } catch (CrowdSecBouncer\BouncerException $e) { 266 | $error = $e->getMessage(); 267 | } 268 | 269 | // Assert that the correct exception message was thrown 270 | $this->assertStringStartsWith('Failed to read stream: Too many loops (3) while reading stream', $error); 271 | 272 | // Clean up the custom stream wrapper 273 | stream_wrapper_unregister('failing'); 274 | } 275 | 276 | protected function setUp(): void 277 | { 278 | $this->root = vfsStream::setup('/tmp'); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /tests/Unit/TemplateTest.php: -------------------------------------------------------------------------------- 1 | render( 31 | [ 32 | 'text' => [ 33 | 'ban_wall' => [ 34 | 'title' => 'BAN TEST TITLE', 35 | ], 36 | ], 37 | ] 38 | ); 39 | 40 | $this->assertStringContainsString('

BAN TEST TITLE

', $render, 'Ban rendering should be as expected'); 41 | $this->assertStringNotContainsString('', $render, 'Ban rendering should contain footer'); 55 | 56 | $template = new Template('captcha.html.twig', __DIR__ . '/../../src/templates'); 57 | $render = $template->render( 58 | [ 59 | 'text' => [ 60 | 'captcha_wall' => [ 61 | 'title' => 'CAPTCHA TEST TITLE', 62 | ], 63 | ], 64 | ] 65 | ); 66 | 67 | $this->assertStringContainsString('CAPTCHA TEST TITLE', $render, 'Captcha rendering should be as expected'); 68 | $this->assertStringContainsString('
', $render, 'Captcha rendering should be as expected'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tools/.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/bouncer": "@dev" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "CrowdSecBouncer\\Tests\\": "../../tests/" 18 | } 19 | }, 20 | "repositories": { 21 | "crowdsec-bouncer-lib": { 22 | "type": "path", 23 | "url": "../../" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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')->exclude(['templates']) 25 | ->in(__DIR__ . '/../../../tests') 26 | ) 27 | ; -------------------------------------------------------------------------------- /tools/coding-standards/phpmd/rulesets.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | Rule set that checks CrowdSec Bouncer PHP lib 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /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 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | --------------------------------------------------------------------------------