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