Skip to content

Commit 94d6877

Browse files
authored
Merge pull request #49 from crowdsecurity/standalone
add standalone mode
2 parents 9cff196 + 5583829 commit 94d6877

File tree

11 files changed

+905
-1
lines changed

11 files changed

+905
-1
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ super-linter.log
1111
# App
1212
/var/
1313
.bouncer-key
14-
.cache
14+
.cache
15+
16+
# Auto prepend demo
17+
examples/auto-prepend/settings.php
18+
examples/auto-prepend/.logs
19+
examples/auto-prepend/.cache

docs/standalone-mode.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Standalone mode
2+
3+
There is many technical ways to bounce users.
4+
5+
In this quick guide, you will learn how to bounce **without modifying the existing code** by running the bouncing behavior just before running the project code. For this, there is an example showing how to do it using the PHP setting `auto_prepend_file`. We will test it now.
6+
7+
## Step 1: Create the settings.php file
8+
9+
Start from the exising `settings.example.php`:
10+
11+
```bash
12+
cd path/to/this/lib
13+
cp examples/auto-prepend/settings.example.php examples/auto-prepend/settings.php
14+
```
15+
16+
> Note: don't forget to replace the values according to your needs in `examples/auto-prepend/settings.php`.
17+
18+
## Step 2: start the "auto_prepend_file" mechanism
19+
20+
Considering you're using Apache, modify (or add) the main `.htaccess` file at the root of your project, adding this single line to the top:
21+
22+
```apacheconf
23+
php_value auto_prepend_file "./path/to/this/lib/examples/auto-prepend/script/bounce-via-autoprepend.php"
24+
```
25+
26+
> Remember to **adapt the path to yours**. You can use absolute or relative.
27+
28+
> **Not using apache?** If you using _NGINX_ or an other webserver, the way to modify the _PHP_ flag is different but still possible (ndlr: add a example).
29+
30+
This will run the `bounce-via-autoprepend.php` script each time before running the main php script. If the IP has to be bounce, the script will show the wall (ban or captcha) and stop execution of the main script.
31+
32+
## Step 3: Test the standalone bouncer
33+
34+
Now you can a decision to ban your own IP for 5 minutes to test the correct behavior:
35+
36+
```bash
37+
cscli decisions add --ip <YOUR_IP> --duration 5m --type ban
38+
```
39+
40+
You can also test a captcha:
41+
42+
```bash
43+
cscli decisions delete --all # be careful with this command!
44+
cscli decisions add --ip <YOUR_IP> --duration 15m --type captcha
45+
```
46+
47+
> Well done! Feel free to give some feedback using adding Github issues.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
php_value auto_prepend_file "../scripts/bounce-via-auto-prepend.php"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<h1>The way is clear!</h1>
2+
<p>In this example page, if you can see this text, the bouncer considers your IP as clean.</p>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
require_once __DIR__.'/../../../vendor/autoload.php';
4+
require_once __DIR__.'/../settings.php';
5+
6+
use CrowdSecBouncer\StandAloneBounce;
7+
8+
$bounce = new StandAloneBounce();
9+
$bounce->init($crowdSecStandaloneBouncerConfig);
10+
$bounce->setDebug($crowdSecStandaloneBouncerConfig['debug_mode']);
11+
$bounce->safelyBounce();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
require_once __DIR__.'/../../../vendor/autoload.php';
4+
require_once __DIR__.'/../settings.php';
5+
6+
use CrowdSecBouncer\StandAloneBounce;
7+
8+
$bounce = new StandAloneBounce();
9+
$bounce->init($crowdSecStandaloneBouncerConfig);
10+
$bouncer = $bounce->getBouncerInstance();
11+
$bouncer->refreshBlocklistCache();
12+
echo 'OK';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
use CrowdSecBouncer\Constants;
4+
5+
$crowdSecStandaloneBouncerConfig = [
6+
'api_url' => 'http://url-to-your-lapi:8080', // [FILL ME] Set the LAPI URL here. Example in the docker-compose dev context, use http://crowdsec:8080
7+
'api_key' => '...', // [FILL ME] Set a bouncer key here
8+
'debug_mode' => false, // [FILL ME] Set to true to stop the process and display errors if any
9+
'log_directory_path' => __DIR__.'/.logs', // [FILL ME] Important note: be sur this path won't be publicly accessible!
10+
'fs_cache_path' => __DIR__.'/.cache', // [FILL ME] Important note: be sur this path won't be publicly accessible!
11+
12+
'bouncing_level' => Constants::BOUNCING_LEVEL_NORMAL,
13+
14+
'stream_mode' => false,
15+
16+
'cache_system' => Constants::CACHE_SYSTEM_PHPFS,
17+
'redis_dsn' => '',
18+
'memcached_dsn' => '',
19+
20+
'clean_ip_cache_duration' => Constants::CACHE_EXPIRATION_FOR_CLEAN_IP,
21+
'bad_ip_cache_duration' => Constants::CACHE_EXPIRATION_FOR_BAD_IP,
22+
'fallback_remediation' => Constants::REMEDIATION_CAPTCHA,
23+
24+
'hide_mentions' => false,
25+
'trust_ip_forward' => '',
26+
'trust_ip_forward_array' => [],
27+
28+
'theme_color_text_primary' => 'black',
29+
'theme_color_text_secondary' => '#AAA',
30+
'theme_color_text_button' => 'white',
31+
'theme_color_text_error_message' => '#b90000',
32+
'theme_color_background_page' => '#eee',
33+
'theme_color_background_container' => 'white',
34+
'theme_color_background_button' => '#626365',
35+
'theme_color_background_button_hover' => '#333',
36+
37+
'theme_text_captcha_wall_tab_title' => 'Oops..',
38+
'theme_text_captcha_wall_title' => 'Hmm, sorry but...',
39+
'theme_text_captcha_wall_subtitle' => 'Please complete the security check.',
40+
'theme_text_captcha_wall_refresh_image_link' => 'refresh image',
41+
'theme_text_captcha_wall_captcha_placeholder' => 'Type here...',
42+
'theme_text_captcha_wall_send_button' => 'CONTINUE',
43+
'theme_text_captcha_wall_error_message' => 'Please try again.',
44+
'theme_text_captcha_wall_footer' => '',
45+
46+
'theme_text_ban_wall_tab_title' => 'Oops..',
47+
'theme_text_ban_wall_title' => '🤭 Oh!',
48+
'theme_text_ban_wall_subtitle' => 'This page is protected against cyber attacks and your IP has been banned by our system.',
49+
'theme_text_ban_wall_footer' => '',
50+
'theme_custom_css' => '',
51+
];

src/AbstractBounce.php

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<?php
2+
3+
namespace CrowdSecBouncer;
4+
5+
require_once __DIR__.'/templates/captcha.php';
6+
require_once __DIR__.'/templates/access-forbidden.php';
7+
8+
use IPLib\Factory;
9+
use Monolog\Formatter\LineFormatter;
10+
use Monolog\Handler\RotatingFileHandler;
11+
use Monolog\Logger;
12+
use Psr\Log\LoggerInterface;
13+
14+
/**
15+
* The class that apply a bounce.
16+
*
17+
* @author CrowdSec team
18+
*
19+
* @see https://crowdsec.net CrowdSec Official Website
20+
*
21+
* @copyright Copyright (c) 2021+ CrowdSec
22+
* @license MIT License
23+
*/
24+
abstract class AbstractBounce
25+
{
26+
/** @var array */
27+
protected $settings = [];
28+
29+
/** @var bool */
30+
protected $debug = false;
31+
32+
/** @var LoggerInterface */
33+
protected $logger;
34+
35+
/** @var Bouncer */
36+
protected $bouncer;
37+
38+
protected function getStringSettings(string $name): string
39+
{
40+
return $this->settings[$name];
41+
}
42+
43+
protected function getArraySettings(string $name): array
44+
{
45+
return $this->settings[$name];
46+
}
47+
48+
/**
49+
* Run a bounce.
50+
*/
51+
public function run(
52+
): void {
53+
if ($this->shouldBounceCurrentIp()) {
54+
$this->bounceCurrentIp();
55+
}
56+
}
57+
58+
public function setDebug(bool $debug)
59+
{
60+
$this->debug = $debug;
61+
}
62+
63+
protected function initLoggerHelper($logDirectoryPath, $loggerName): void
64+
{
65+
// Singleton for this function
66+
if ($this->logger) {
67+
return;
68+
}
69+
70+
$this->logger = new Logger($loggerName);
71+
$logPath = $logDirectoryPath.'/prod.log';
72+
$fileHandler = new RotatingFileHandler($logPath, 0, Logger::INFO);
73+
$fileHandler->setFormatter(new LineFormatter("%datetime%|%level%|%context%\n"));
74+
$this->logger->pushHandler($fileHandler);
75+
76+
// Set custom readable logger when debug=true
77+
if ($this->debug) {
78+
$debugLogPath = $logDirectoryPath.'/debug.log';
79+
$debugFileHandler = new RotatingFileHandler($debugLogPath, 0, Logger::DEBUG);
80+
if (class_exists('\Bramus\Monolog\Formatter\ColoredLineFormatter')) {
81+
$debugFileHandler->setFormatter(new \Bramus\Monolog\Formatter\ColoredLineFormatter(null, "[%datetime%] %message% %context%\n", 'H:i:s'));
82+
$this->logger->pushHandler($debugFileHandler);
83+
}
84+
}
85+
}
86+
87+
protected function bounceCurrentIp()
88+
{
89+
$ip = $this->getRemoteIp();
90+
91+
// X-Forwarded-For override
92+
$XForwardedForHeader = $this->getHttpRequestHeader('X-Forwarded-For');
93+
if (null !== $XForwardedForHeader) {
94+
$ipList = array_map('trim', array_values(array_filter(explode(',', $XForwardedForHeader))));
95+
$forwardedIp = end($ipList);
96+
if ($this->shouldTrustXforwardedFor($ip)) {
97+
$ip = $forwardedIp;
98+
} else {
99+
$this->logger->warning('', [
100+
'type' => 'NON_AUTHORIZED_X_FORWARDED_FOR_USAGE',
101+
'original_ip' => $ip,
102+
'x_forwarded_for_ip' => $forwardedIp,
103+
]);
104+
}
105+
}
106+
107+
try {
108+
$this->getBouncerInstance();
109+
$remediation = $this->bouncer->getRemediationForIp($ip);
110+
$this->handleRemediation($remediation, $ip);
111+
} catch (\Exception $e) {
112+
$this->logger->warning('', [
113+
'type' => 'UNKNOWN_EXCEPTION_WHILE_BOUNCING',
114+
'ip' => $ip,
115+
'messsage' => $e->getMessage(),
116+
'code' => $e->getCode(),
117+
'file' => $e->getFile(),
118+
'line' => $e->getLine(),
119+
]);
120+
if ($this->debug) {
121+
throw $e;
122+
}
123+
}
124+
}
125+
126+
protected function shouldTrustXforwardedFor(string $ip): bool
127+
{
128+
$comparableAddress = Factory::addressFromString($ip)->getComparableString();
129+
foreach ($this->getTrustForwardedIpBoundsList() as $comparableIpBounds) {
130+
if ($comparableAddress >= $comparableIpBounds[0] && $comparableAddress <= $comparableIpBounds[1]) {
131+
return true;
132+
}
133+
}
134+
135+
return false;
136+
}
137+
138+
protected function displayCaptchaWall()
139+
{
140+
$options = $this->getCaptchaWallOptions();
141+
$body = Bouncer::getCaptchaHtmlTemplate(
142+
$this->getSessionVariable('crowdsec_captcha_resolution_failed'),
143+
$this->getSessionVariable('crowdsec_captcha_inline_image'),
144+
'',
145+
$options
146+
);
147+
$this->sendResponse($body, 401);
148+
}
149+
150+
protected function handleBanRemediation()
151+
{
152+
$options = $this->getBanWallOptions();
153+
$body = Bouncer::getAccessForbiddenHtmlTemplate($options);
154+
$this->sendResponse($body, 403);
155+
}
156+
157+
protected function storeNewCaptchaCoupleInSession()
158+
{
159+
$captchaCouple = Bouncer::buildCaptchaCouple();
160+
$this->setSessionVariable('crowdsec_captcha_phrase_to_guess', $captchaCouple['phrase']);
161+
$this->setSessionVariable('crowdsec_captcha_inline_image', $captchaCouple['inlineImage']);
162+
}
163+
164+
protected function clearCaptchaSessionContext()
165+
{
166+
$this->unsetSessionVariable('crowdsec_captcha_has_to_be_resolved');
167+
$this->unsetSessionVariable('crowdsec_captcha_phrase_to_guess');
168+
$this->unsetSessionVariable('crowdsec_captcha_inline_image');
169+
$this->unsetSessionVariable('crowdsec_captcha_resolution_failed');
170+
}
171+
172+
protected function handleCaptchaResolutionForm(string $ip)
173+
{
174+
// Early return if no captcha has to be resolved or if captcha already resolved.
175+
if (\in_array($this->getSessionVariable('crowdsec_captcha_has_to_be_resolved'), [null, false])) {
176+
return;
177+
}
178+
179+
// Early return if no form captcha form has been filled.
180+
if ('POST' !== $this->getHttpMethod() || null === $this->getPostedVariable('crowdsec_captcha')) {
181+
return;
182+
}
183+
184+
// Handle image refresh.
185+
if (null !== $this->getPostedVariable('refresh') && (bool) (int) $this->getPostedVariable('refresh')) {
186+
// Generate new captcha image for the user
187+
$this->storeNewCaptchaCoupleInSession();
188+
$this->setSessionVariable('crowdsec_captcha_resolution_failed', false);
189+
190+
return;
191+
}
192+
193+
// Handle a captcha resolution try
194+
if (null !== $this->getPostedVariable('phrase') && null !== $this->getSessionVariable('crowdsec_captcha_phrase_to_guess')) {
195+
$this->getBouncerInstance();
196+
if ($this->bouncer->checkCaptcha(
197+
$this->getSessionVariable('crowdsec_captcha_phrase_to_guess'),
198+
$this->getPostedVariable('phrase'),
199+
$ip)) {
200+
// User has correctly fill the captcha
201+
202+
$this->setSessionVariable('crowdsec_captcha_has_to_be_resolved', false);
203+
$this->unsetSessionVariable('crowdsec_captcha_phrase_to_guess');
204+
$this->unsetSessionVariable('crowdsec_captcha_inline_image');
205+
$this->unsetSessionVariable('crowdsec_captcha_resolution_failed');
206+
} else {
207+
// The user failed to resolve the captcha.
208+
$this->setSessionVariable('crowdsec_captcha_resolution_failed', true);
209+
}
210+
}
211+
}
212+
213+
protected function handleCaptchaRemediation($ip)
214+
{
215+
// Check captcha resolution form
216+
$this->handleCaptchaResolutionForm($ip);
217+
218+
if (null === $this->getSessionVariable('crowdsec_captcha_has_to_be_resolved')) {
219+
// Setup the first captcha remediation.
220+
221+
$this->storeNewCaptchaCoupleInSession();
222+
$this->setSessionVariable('crowdsec_captcha_has_to_be_resolved', true);
223+
$this->setSessionVariable('crowdsec_captcha_resolution_failed', false);
224+
}
225+
226+
// Display captcha page if this is required.
227+
if ($this->getSessionVariable('crowdsec_captcha_has_to_be_resolved')) {
228+
$this->displayCaptchaWall();
229+
}
230+
}
231+
232+
protected function handleRemediation(string $remediation, string $ip)
233+
{
234+
if (Constants::REMEDIATION_CAPTCHA !== $remediation && null !== $this->getSessionVariable('crowdsec_captcha_has_to_be_resolved')) {
235+
$this->clearCaptchaSessionContext();
236+
}
237+
switch ($remediation) {
238+
case Constants::REMEDIATION_BYPASS:
239+
return;
240+
case Constants::REMEDIATION_CAPTCHA:
241+
$this->handleCaptchaRemediation($ip);
242+
break;
243+
case Constants::REMEDIATION_BAN:
244+
$this->handleBanRemediation();
245+
break;
246+
default:
247+
return;
248+
}
249+
}
250+
}

0 commit comments

Comments
 (0)