Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ $abuse = new Abuse($adapter);

// Use vars to resolve adapter key

if(!$abuse->check()) {
if($abuse->isSafe() === false) {
throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here
}
```
Expand All @@ -77,18 +77,18 @@ To use this adapter you need to create an API key from the Google ReCaptcha serv
require_once __DIR__ . '/../../vendor/autoload.php';

use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\ReCaptcha;
use Utopia\Abuse\Adapters\ReCaptchaLike\ReCaptcha;

// Limit login attempts to 10 time in 5 minutes time frame
$adapter = new ReCaptcha('secret-api-key', $_POST['g-recaptcha-response'], $_SERVER['REMOTE_ADDR']);
$abuse = new Abuse($adapter);

if(!$abuse->check()) {
if($abuse->isSafe() === false) {
throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here
}
```

*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDE‌​D_FOR header.*
*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDED_FOR header.*

## System Requirements

Expand Down
11 changes: 11 additions & 0 deletions src/Abuse/Abuse.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ public function __construct(Adapter $adapter)
* Checks if request is considered abuse or not
*
* @return bool
* @deprecated Check is ambiguous, use isSafe instead
*/
public function check(): bool
{
return $this->adapter->check();
}

/**
* Main method for threat detection
*
* @return bool Returns true if is safe to continue
*/
public function isSafe(): bool
{
return $this->adapter->isSafe();
}

/**
* Get abuse logs
*
Expand Down
8 changes: 8 additions & 0 deletions src/Abuse/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ interface Adapter
* Checks if number of counts is bigger or smaller than current limit
*
* @return bool
* @deprecated Check is ambiguous, use isSafe instead
*/
public function check(): bool;

/**
* Main method for threat detection
*
* @return bool Returns true if is safe to continue
*/
public function isSafe(): bool;

/**
* Get abuse logs
*
Expand Down
25 changes: 25 additions & 0 deletions src/Abuse/Adapters/ReCaptchaLike/CfTurnstile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Utopia\Abuse\Adapters\ReCaptchaLike;

class CfTurnstile extends ReCaptchaLike
{
/**
* @inheritDoc
*/
protected function decideByResult(array $result): bool
{
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
return $result['success'];
}

/**
* @inheritDoc
*/
protected function getSiteVerifyUrl(): string
{
return 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
}
}
25 changes: 25 additions & 0 deletions src/Abuse/Adapters/ReCaptchaLike/HCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Utopia\Abuse\Adapters\ReCaptchaLike;

class HCaptcha extends ReCaptchaLike
{
/**
* @inheritDoc
*/
protected function decideByResult(array $result): bool
{
// hCaptcha Enterprise scores are risk scores, and thus they run from 0.0 (no risk) to 1.0 (confirmed threat).
return $result['success'] && $result['score'] < $this->threshold;
}

/**
* @inheritDoc
*/
protected function getSiteVerifyUrl(): string
{
return 'https://api.hcaptcha.com/siteverify';
}
}
25 changes: 25 additions & 0 deletions src/Abuse/Adapters/ReCaptchaLike/ReCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Utopia\Abuse\Adapters\ReCaptchaLike;

class ReCaptcha extends ReCaptchaLike
{
/**
* @inheritDoc
*/
protected function decideByResult(array $result): bool
{
// reCAPTCHA v3 returns a score (1.0 is very likely a good interaction, 0.0 is very likely a bot) @see https://developers.google.com/recaptcha/docs/v3#interpreting_the_score
return $result['success'] && $result['score'] > $this->threshold;
}

/**
* @inheritDoc
*/
protected function getSiteVerifyUrl(): string
{
return 'https://www.google.com/recaptcha/api/siteverify';
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<?php

namespace Utopia\Abuse\Adapters;
namespace Utopia\Abuse\Adapters\ReCaptchaLike;

use Exception;
use Utopia\Abuse\Adapter;

class ReCaptcha implements Adapter
abstract class ReCaptchaLike implements Adapter
{
/**
* Use this for communication between your site and Google.
Expand All @@ -29,6 +29,11 @@ class ReCaptcha implements Adapter
*/
protected string $remoteIP = '';

/**
* Threshold that is used to determine threats
*/
protected float $threshold;

/**
* ReCaptcha Adapter
*
Expand All @@ -42,25 +47,31 @@ class ReCaptcha implements Adapter
* @param string $secret
* @param string $response
* @param string $remoteIP
* @param float $threshold By default, you can use a threshold of 0.5. @see https://developers.google.com/recaptcha/docs/v3#interpreting_the_score
*/
public function __construct(string $secret, string $response, string $remoteIP)
public function __construct(string $secret, string $response, string $remoteIP, float $threshold = 0.5)
{
$this->secret = $secret;
$this->response = $response;
$this->remoteIP = $remoteIP;
$this->threshold = $threshold;
}

/**
* Check
*
* Check if user is human or not, compared to score
*
* @param float $score
* @return bool
* @inheritDoc
*/
public function check(float $score = 0.5): bool
{
$url = 'https://www.google.com/recaptcha/api/siteverify';
$this->threshold = $score;

return $this->isSafe() === false;
}

/**
* @inheritDoc
*/
public function isSafe(): bool
{
$fields = [
'secret' => \urlencode($this->secret),
'response' => \urlencode($this->response),
Expand All @@ -71,24 +82,40 @@ public function check(float $score = 0.5): bool
$ch = \curl_init();

//set the url, number of POST vars, POST data
\curl_setopt($ch, CURLOPT_URL, $url);
\curl_setopt($ch, CURLOPT_URL, $this->getSiteVerifyUrl());
\curl_setopt($ch, CURLOPT_POST, \count($fields));
\curl_setopt($ch, CURLOPT_POSTFIELDS, \http_build_query($fields));
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

//execute post
/** @var array<string, mixed> $result */
$result = \json_decode((string) \curl_exec($ch), true);

//close connection
\curl_close($ch);
if ($result['success'] && $result['score'] >= $score) {
return true;
} else {
try {
$result = \json_decode((string)\curl_exec($ch), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $_) {
return false;
} finally {
//close connection
\curl_close($ch);
}

return $this->decideByResult($result);
}

/**
* Implementation how score is interpreted.
*
* @param array $result Returned by reCAPTCHA service
* @return bool True if is safe
*/
abstract protected function decideByResult(array $result): bool;

/**
* Implementation how to get site-verify url.
*
* @return string Url of the site-verify endpoint.
*/
abstract protected function getSiteVerifyUrl(): string;

/**
* Delete logs older than $datetime
*
Expand Down
34 changes: 21 additions & 13 deletions src/Abuse/Adapters/TimeLimit.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class TimeLimit implements Adapter
/**
* @param string $key
* @param int $seconds
* @param int $limit
* @param int $limit Number of requests in given time window. 0 means unlimited.
* @param Database $db
*/
public function __construct(string $key, int $limit, int $seconds, Database $db)
Expand Down Expand Up @@ -314,28 +314,36 @@ public function cleanup(string $datetime): bool
}

/**
* Check
*
* Checks if number of counts is bigger or smaller than current limit. limit 0 is equal to unlimited
*
* @return bool
*
* @throws \Exception|Throwable
* @inheritDoc
* @throws AuthorizationException
* @throws Throwable
* @throws Structure
*/
public function check(): bool
{
if (0 == $this->limit) {
return false;
return $this->isSafe() === false;
}

/**
* @inheritDoc
* @throws AuthorizationException
* @throws Throwable
* @throws Structure
*/
public function isSafe(): bool
{
if (0 === $this->limit) {
return true;
}

$key = $this->parseKey();

if ($this->limit > $this->count($key, $this->time)) {
$this->hit($key, $this->time);

if ($this->limit <= $this->count($key, $this->time)) {
return false;
}

$this->hit($key, $this->time);

return true;
}

Expand Down
33 changes: 24 additions & 9 deletions tests/Abuse/AbuseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public function setUp(): void

public function tearDown(): void
{
unset($this->abuse);
unset($this->abuse, $this->abuseIp);
}

public function testImitate2Requests(): void
Expand All @@ -64,26 +64,41 @@ public function testImitate2Requests(): void
$adapter = new TimeLimit($key, 1, 1, $this->db);
$adapter->setParam($key, $value);
$this->abuseIp = new Abuse($adapter);
$this->assertEquals($this->abuseIp->check(), false);
$this->assertEquals($this->abuseIp->check(), true);

$this->assertEquals($this->abuseIp->isSafe(), true);
$this->assertEquals($this->abuseIp->isSafe(), false);

sleep(1);

$adapter = new TimeLimit($key, 1, 1, $this->db);
$adapter->setParam($key, $value);
$this->abuseIp = new Abuse($adapter);

$this->assertEquals($this->abuseIp->check(), false);
$this->assertEquals($this->abuseIp->check(), true);
$this->assertEquals($this->abuseIp->isSafe(), true);
$this->assertEquals($this->abuseIp->isSafe(), false);
}

public function testUnlimitedRequests(): void
{
$key = '{{ip}}';
$value = '0.0.0.10';

$adapter = new TimeLimit($key, 0, 1, $this->db);
$adapter->setParam($key, $value);
$this->abuseIp = new Abuse($adapter);

for ($i = 0; $i < 100; $i++) {
$this->assertEquals($this->abuseIp->isSafe(), true);
}
}

public function testIsValid(): void
{
// Use vars to resolve adapter key
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), true);
$this->assertEquals($this->abuse->isSafe(), true);
$this->assertEquals($this->abuse->isSafe(), true);
$this->assertEquals($this->abuse->isSafe(), true);
$this->assertEquals($this->abuse->isSafe(), false);
}

public function testCleanup(): void
Expand Down