Files
projectsend/tests/Feature/Platform/CaptchaVerificationTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

347 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Modules\Platform\Captcha\CaptchaProvider;
use App\Modules\Platform\Captcha\CaptchaResult;
use App\Modules\Platform\Captcha\CaptchaSettings;
use App\Modules\Platform\Captcha\CaptchaVerifier;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Http;
const TURNSTILE_VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const RECAPTCHA_VERIFY = 'https://www.google.com/recaptcha/api/siteverify';
beforeEach(function () {
User::factory()->create();
// Settings survive the per-test DB rollback (Cache::rememberForever),
// and so does the display cache — set everything this file depends on
// rather than assuming a default.
$settings = app(Settings::class);
$settings->set(Setting::CaptchaProvider, 'none');
$settings->set(Setting::CaptchaKeySource, 'own');
foreach (CaptchaForm::cases() as $form) {
$settings->set($form->setting(), true);
}
config()->set('projectsend.captcha.disabled', false);
config()->set('projectsend.captcha.managed', ['provider' => null, 'site_key' => null, 'secret_key' => null, 'score_threshold' => 0.5]);
Captcha::forgetDisplayCache();
CaptchaVerifier::forgetOutage();
});
/** Configure this installation with its own keys for one provider. */
function useProvider(CaptchaProvider $provider, ?float $threshold = null): void
{
$row = CaptchaSettings::for($provider);
$row->site_key = 'site-key';
$row->secret_key = 'secret-key';
$row->score_threshold = $threshold ?? CaptchaSettings::DEFAULT_SCORE_THRESHOLD;
$row->save();
app(Settings::class)->set(Setting::CaptchaProvider, $provider->value);
Captcha::forgetDisplayCache();
}
function verify(CaptchaForm $form = CaptchaForm::Login): CaptchaResult
{
return app(CaptchaVerifier::class)->verify('a-token', $form, '203.0.113.7');
}
test('a good token passes, for every provider', function (CaptchaProvider $provider, string $url) {
useProvider($provider);
Http::fake([$url => Http::response([
'success' => true,
'action' => 'login',
'score' => 0.9,
'hostname' => 'localhost',
])]);
expect(verify())->toBe(CaptchaResult::Passed);
Http::assertSent(fn ($request) => $request['secret'] === 'secret-key'
&& $request['response'] === 'a-token'
&& $request['remoteip'] === '203.0.113.7');
})->with([
'turnstile' => [CaptchaProvider::Turnstile, TURNSTILE_VERIFY],
'recaptcha v2' => [CaptchaProvider::RecaptchaV2, RECAPTCHA_VERIFY],
'recaptcha v3' => [CaptchaProvider::RecaptchaV3, RECAPTCHA_VERIFY],
]);
test('a refusal the provider blames on the visitor fails', function () {
useProvider(CaptchaProvider::Turnstile);
Http::fake([TURNSTILE_VERIFY => Http::response([
'success' => false,
'error-codes' => ['invalid-input-response'],
])]);
expect(verify())->toBe(CaptchaResult::Failed);
});
// v1 decided reCAPTCHA v2's answer with strstr($body, '"success": true'),
// so any response whose text happened to contain that substring passed.
test('a failure is read from the decoded body, not searched for in its text', function () {
useProvider(CaptchaProvider::RecaptchaV2);
Http::fake([RECAPTCHA_VERIFY => Http::response([
'success' => false,
'error-codes' => ['invalid-input-response'],
'message' => 'expected "success": true but the token was already used',
])]);
expect(verify())->toBe(CaptchaResult::Failed);
});
// v1 minted every token with the action "submit" and never compared it, so
// a token from the login form was accepted at registration.
test('a token minted for another form is refused', function (CaptchaProvider $provider, string $url) {
useProvider($provider);
Http::fake([$url => Http::response([
'success' => true,
'action' => 'register',
'score' => 0.9,
])]);
expect(verify(CaptchaForm::Login))->toBe(CaptchaResult::Failed);
})->with([
'turnstile' => [CaptchaProvider::Turnstile, TURNSTILE_VERIFY],
'recaptcha v3' => [CaptchaProvider::RecaptchaV3, RECAPTCHA_VERIFY],
]);
// Cloudflare's own always-pass testing keys answer this way, so refusing
// it would break every developer's local setup for no gain: a token minted
// without an action was never bound to a form to begin with.
test('a response with no action is accepted', function (CaptchaProvider $provider, string $url) {
useProvider($provider);
Http::fake([$url => Http::response(['success' => true, 'score' => 0.9, 'hostname' => 'example.com'])]);
expect(verify(CaptchaForm::Login))->toBe(CaptchaResult::Passed);
})->with([
'turnstile' => [CaptchaProvider::Turnstile, TURNSTILE_VERIFY],
'recaptcha v3' => [CaptchaProvider::RecaptchaV3, RECAPTCHA_VERIFY],
]);
test('reCAPTCHA v2 passes without an action, because it never sends one', function () {
useProvider(CaptchaProvider::RecaptchaV2);
Http::fake([RECAPTCHA_VERIFY => Http::response(['success' => true, 'hostname' => 'localhost'])]);
expect(verify())->toBe(CaptchaResult::Passed);
});
test('a v3 score below the threshold fails', function () {
useProvider(CaptchaProvider::RecaptchaV3, threshold: 0.7);
Http::fake([RECAPTCHA_VERIFY => Http::response([
'success' => true,
'action' => 'login',
'score' => 0.6,
])]);
expect(verify())->toBe(CaptchaResult::Failed);
});
test('a threshold of zero accepts every score', function () {
// v1's !empty() check turned a saved 0 back into 0.5.
useProvider(CaptchaProvider::RecaptchaV3, threshold: 0.0);
Http::fake([RECAPTCHA_VERIFY => Http::response([
'success' => true,
'action' => 'login',
'score' => 0.0,
])]);
expect(verify())->toBe(CaptchaResult::Passed);
});
test('a provider that cannot be reached is unavailable, not a failure', function (callable $fake) {
useProvider(CaptchaProvider::Turnstile);
Http::fake([TURNSTILE_VERIFY => $fake()]);
$result = verify();
expect($result)->toBe(CaptchaResult::Unavailable)
->and($result->allowsRequest())->toBeTrue();
})->with([
'connection refused' => [fn () => fn () => Http::failedConnection()],
'server error' => [fn () => fn () => Http::response(null, 500)],
'unparseable body' => [fn () => fn () => Http::response('<html>maintenance</html>', 200)],
]);
// The single most important property here: the likeliest misconfiguration
// is a mistyped secret, and it must not lock anybody out.
test('our own bad credentials are unavailable, so nobody is locked out', function (string $code) {
useProvider(CaptchaProvider::Turnstile);
Http::fake([TURNSTILE_VERIFY => Http::response(['success' => false, 'error-codes' => [$code]])]);
expect(verify())->toBe(CaptchaResult::Unavailable);
expect(CaptchaVerifier::lastError())
->not->toBeNull()
->and(CaptchaVerifier::lastError()['our_credentials'])->toBeTrue();
})->with(['invalid-input-secret', 'missing-input-secret', 'bad-request', 'internal-error']);
// Cloudflare answers a malformed secret with 400 *and* the error code
// together. Reading the status alone would file it as "could not reach
// them", and the administrator would never learn it was their key.
test('a rejected secret is reported as a rejected secret, even behind a 400', function () {
useProvider(CaptchaProvider::Turnstile);
Http::fake([TURNSTILE_VERIFY => Http::response(
['success' => false, 'error-codes' => ['invalid-input-secret']],
400,
)]);
expect(verify())->toBe(CaptchaResult::Unavailable);
expect(CaptchaVerifier::lastError())
->not->toBeNull()
->and(CaptchaVerifier::lastError()['our_credentials'])->toBeTrue()
->and(CaptchaVerifier::lastError()['codes'])->toBe(['invalid-input-secret']);
});
test('the breaker stops a second call while the provider is down', function () {
useProvider(CaptchaProvider::Turnstile);
Http::fake([TURNSTILE_VERIFY => Http::failedConnection()]);
expect(verify())->toBe(CaptchaResult::Unavailable)
->and(verify())->toBe(CaptchaResult::Unavailable);
Http::assertSentCount(1);
});
test('nothing is verified when no provider is configured', function () {
Http::fake();
expect(verify())->toBe(CaptchaResult::Passed);
Http::assertNothingSent();
});
test('half-configured keys behave exactly as switched off', function () {
$row = CaptchaSettings::for(CaptchaProvider::Turnstile);
$row->site_key = 'site-key';
$row->secret_key = null;
$row->save();
app(Settings::class)->set(Setting::CaptchaProvider, CaptchaProvider::Turnstile->value);
Captcha::forgetDisplayCache();
expect(app(Captcha::class)->active())->toBeNull()
->and(app(Captcha::class)->protects(CaptchaForm::Login))->toBeFalse();
});
test('a form whose switch is off is not protected, while the others still are', function () {
useProvider(CaptchaProvider::Turnstile);
app(Settings::class)->set(Setting::CaptchaOnLogin, false);
$captcha = app(Captcha::class);
expect($captcha->protects(CaptchaForm::Login))->toBeFalse()
->and($captcha->protects(CaptchaForm::Register))->toBeTrue();
});
test('the env kill switch turns everything off', function () {
useProvider(CaptchaProvider::Turnstile);
config()->set('projectsend.captcha.disabled', true);
expect(app(Captcha::class)->active())->toBeNull();
});
test('community ignores a stored managed key source and uses its own keys', function () {
config()->set('projectsend.edition', Edition::Community);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'platform-site-key',
'secret_key' => 'platform-secret',
'score_threshold' => 0.5,
]);
useProvider(CaptchaProvider::RecaptchaV2);
// A value that could only have arrived from a v1 import, a hand-edited
// row, or an install that used to be cloud.
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
Captcha::forgetDisplayCache();
$active = app(Captcha::class)->active();
expect($active)->not->toBeNull()
->and($active->provider)->toBe(CaptchaProvider::RecaptchaV2)
->and($active->siteKey)->toBe('site-key')
->and($active->managed)->toBeFalse();
});
test('cloud on managed keys uses the platform credentials, not the tenant row', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'platform-site-key',
'secret_key' => 'platform-secret',
'score_threshold' => 0.5,
]);
useProvider(CaptchaProvider::RecaptchaV2);
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
Captcha::forgetDisplayCache();
$active = app(Captcha::class)->active();
expect($active->provider)->toBe(CaptchaProvider::Turnstile)
->and($active->siteKey)->toBe('platform-site-key')
->and($active->managed)->toBeTrue();
// And the browser is told the platform's site key and nothing secret.
expect(app(Captcha::class)->forDisplay())
->toMatchArray(['provider' => 'turnstile', 'site_key' => 'platform-site-key']);
});
test('cloud on managed keys with nothing configured is simply off', function () {
config()->set('projectsend.edition', Edition::Cloud);
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
Captcha::forgetDisplayCache();
expect(app(Captcha::class)->active())->toBeNull()
->and(app(Captcha::class)->forDisplay())->toBeNull();
});
test('an incomplete own configuration does not silently fall back to the platform keys', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'platform-site-key',
'secret_key' => 'platform-secret',
'score_threshold' => 0.5,
]);
app(Settings::class)->set(Setting::CaptchaKeySource, 'own');
app(Settings::class)->set(Setting::CaptchaProvider, CaptchaProvider::Turnstile->value);
Captcha::forgetDisplayCache();
expect(app(Captcha::class)->active())->toBeNull();
});
test('the secret is encrypted at rest', function () {
useProvider(CaptchaProvider::Turnstile);
$stored = DB::table('captcha_providers')->where('provider', 'turnstile')->value('secret_key');
expect($stored)->not->toBe('secret-key')
->and(CaptchaSettings::for(CaptchaProvider::Turnstile)->secret_key)->toBe('secret-key');
});