mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
d32788e4a1
The screen is open in both editions and stays that way by default, so a self-hosted installation loses nothing: nobody else supplies its keys, and nobody else is affected by what it decides. What the key buys is the ability to take it away. A hosted fleet puts every tenant on one parent domain and one sending reputation, so an administrator who turns their own CAPTCHA off is spending everybody else's deliverability rather than only their own. That is not the shape LDAP and social login have, which is why those two stay ungated and this one does not. Gated all-or-nothing on the route, read included, exactly as Storage and Branding are. Per-field gating in the controller would not have closed it: switching the CAPTCHA off needs none of the gated fields — `provider: none` does it, and so does unticking the four per-form switches while leaving good keys in place — so the PATCH had to be closed too, and the middleware closes both verbs at once. Which keys the screen may offer is still the separate, narrower question Capability::CaptchaManagedKeys answers per field. An operator withdraws it by naming captcha.configure in PROJECTSEND_CAPABILITIES_DISABLED. Note that the key also joins the list `projectsend:status` and GET /api/v1/me report, which is additive — the OpenAPI document types capabilities as an untyped array, so nothing there needed regenerating.
337 lines
13 KiB
PHP
337 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\CaptchaSettings;
|
|
use App\Modules\Platform\Captcha\CaptchaVerifier;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
|
|
// Settings and the display payload both outlive the per-test DB
|
|
// rollback, so nothing here may assume 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.edition', Edition::Community);
|
|
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();
|
|
});
|
|
|
|
/** The payload the form posts, with only the interesting bits overridden. */
|
|
function captchaPayload(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'provider' => 'none',
|
|
'on_login' => true,
|
|
'on_registration' => true,
|
|
'on_password_reset' => true,
|
|
'on_public_comments' => true,
|
|
], $overrides);
|
|
}
|
|
|
|
test('the screen needs the settings permission', function () {
|
|
$this->actingAs(staffWithPermissions([]))->get('/system/settings/captcha')->assertForbidden();
|
|
|
|
$this->actingAs(staffWithPermissions(['edit_settings']))->get('/system/settings/captcha')->assertOk();
|
|
});
|
|
|
|
test('saving keys switches the captcha on', function () {
|
|
$this->actingAs($this->admin)
|
|
->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
]))
|
|
->assertRedirect();
|
|
|
|
$active = app(Captcha::class)->active();
|
|
|
|
expect($active)->not->toBeNull()
|
|
->and($active->provider)->toBe(CaptchaProvider::Turnstile)
|
|
->and($active->siteKey)->toBe('site-abc')
|
|
->and($active->secretKey)->toBe('secret-abc');
|
|
});
|
|
|
|
test('the secret is never sent to the browser, only the fact that there is one', function () {
|
|
CaptchaSettings::for(CaptchaProvider::Turnstile)->fill([
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
])->save();
|
|
|
|
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
|
|
Captcha::forgetDisplayCache();
|
|
|
|
$response = $this->actingAs($this->admin)->get('/system/settings/captcha');
|
|
|
|
$response->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('system/settings/captcha')
|
|
->where('providers.0.provider', 'turnstile')
|
|
->where('providers.0.site_key', 'site-abc')
|
|
->where('providers.0.has_secret_key', true)
|
|
);
|
|
|
|
expect($response->content())->not->toContain('secret-abc');
|
|
});
|
|
|
|
test('the secret is encrypted in the database', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
]));
|
|
|
|
expect(DB::table('captcha_providers')->where('provider', 'turnstile')->value('secret_key'))
|
|
->not->toBe('secret-abc');
|
|
});
|
|
|
|
test('a blank secret keeps the stored one, so editing anything else does not wipe it', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
]));
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-changed',
|
|
'secret_key' => '',
|
|
'on_login' => false,
|
|
]));
|
|
|
|
$stored = CaptchaSettings::for(CaptchaProvider::Turnstile);
|
|
|
|
expect($stored->site_key)->toBe('site-changed')
|
|
->and($stored->secret_key)->toBe('secret-abc');
|
|
});
|
|
|
|
test('a provider cannot be switched on half-configured', function () {
|
|
$this->actingAs($this->admin)
|
|
->patch('/system/settings/captcha', captchaPayload(['provider' => 'turnstile', 'site_key' => '', 'secret_key' => '']))
|
|
->assertSessionHasErrors(['site_key', 'secret_key']);
|
|
});
|
|
|
|
test('a v3 threshold outside 0 to 1 is refused', function () {
|
|
$this->actingAs($this->admin)
|
|
->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'recaptcha_v3',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
'score_threshold' => 1.5,
|
|
]))
|
|
->assertSessionHasErrors('score_threshold');
|
|
});
|
|
|
|
test('keys are kept per provider, so comparing two of them costs nothing', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile', 'site_key' => 'turnstile-site', 'secret_key' => 'turnstile-secret',
|
|
]));
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'recaptcha_v2', 'site_key' => 'google-site', 'secret_key' => 'google-secret',
|
|
]));
|
|
|
|
expect(CaptchaSettings::for(CaptchaProvider::Turnstile)->site_key)->toBe('turnstile-site')
|
|
->and(CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->site_key)->toBe('google-site');
|
|
});
|
|
|
|
test('the per-form switches are saved', function () {
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
'on_login' => false,
|
|
'on_public_comments' => false,
|
|
]));
|
|
|
|
$captcha = app(Captcha::class);
|
|
|
|
expect($captcha->protects(CaptchaForm::Login))->toBeFalse()
|
|
->and($captcha->protects(CaptchaForm::Comment))->toBeFalse()
|
|
->and($captcha->protects(CaptchaForm::Register))->toBeTrue();
|
|
});
|
|
|
|
test('community is never offered the platform keys, and cannot be made to use them', function () {
|
|
config()->set('projectsend.captcha.managed', [
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'platform-site',
|
|
'secret_key' => 'platform-secret',
|
|
'score_threshold' => 0.5,
|
|
]);
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('managed_keys_available', false)
|
|
);
|
|
|
|
// A hand-crafted PATCH: the field is not in the rules and is never
|
|
// read, so it cannot smuggle the platform's credentials into a
|
|
// self-hosted install.
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'key_source' => 'managed',
|
|
'provider' => 'recaptcha_v2',
|
|
'site_key' => 'own-site',
|
|
'secret_key' => 'own-secret',
|
|
]));
|
|
|
|
expect(app(Settings::class)->get(Setting::CaptchaKeySource))->toBe('own')
|
|
->and(app(Captcha::class)->active()->siteKey)->toBe('own-site');
|
|
});
|
|
|
|
test('cloud can choose the platform keys, and choosing them leaves its own alone', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
config()->set('projectsend.captcha.managed', [
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'platform-site',
|
|
'secret_key' => 'platform-secret',
|
|
'score_threshold' => 0.5,
|
|
]);
|
|
|
|
CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->fill([
|
|
'site_key' => 'tenant-site',
|
|
'secret_key' => 'tenant-secret',
|
|
])->save();
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', [
|
|
'key_source' => 'managed',
|
|
'on_login' => true,
|
|
'on_registration' => true,
|
|
'on_password_reset' => true,
|
|
'on_public_comments' => true,
|
|
])->assertRedirect();
|
|
|
|
expect(app(Captcha::class)->active()->siteKey)->toBe('platform-site')
|
|
// Still there, so flipping back needs no re-keying.
|
|
->and(CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->site_key)->toBe('tenant-site');
|
|
});
|
|
|
|
test('the test button proves a secret without anybody solving a challenge', function () {
|
|
Http::fake([
|
|
'challenges.cloudflare.com/*' => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]),
|
|
]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'secret-abc'])
|
|
->assertRedirect()
|
|
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === true);
|
|
});
|
|
|
|
// Cloudflare's always-pass testing secret verifies anything, including
|
|
// the probe. Without a branch for it, the clearest possible "your key
|
|
// works" reads as an unexpected reply.
|
|
test('the test button accepts a secret that verifies the probe outright', function () {
|
|
Http::fake(['challenges.cloudflare.com/*' => Http::response(['success' => true])]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'testing-key'])
|
|
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === true);
|
|
});
|
|
|
|
test('the test button reports a rejected secret as rejected', function () {
|
|
Http::fake([
|
|
'challenges.cloudflare.com/*' => Http::response(['success' => false, 'error-codes' => ['invalid-input-secret']]),
|
|
]);
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'wrong'])
|
|
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === false);
|
|
});
|
|
|
|
test('saving clears a stale outage warning', function () {
|
|
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
|
|
|
|
CaptchaSettings::for(CaptchaProvider::Turnstile)->fill(['site_key' => 's', 'secret_key' => 'k'])->save();
|
|
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
|
|
Captcha::forgetDisplayCache();
|
|
|
|
app(CaptchaVerifier::class)->verify('token', CaptchaForm::Login);
|
|
expect(CaptchaVerifier::lastError())->not->toBeNull();
|
|
|
|
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile', 'site_key' => 's', 'secret_key' => 'corrected',
|
|
]));
|
|
|
|
expect(CaptchaVerifier::lastError())->toBeNull();
|
|
});
|
|
|
|
// Capability::CaptchaConfigure. Present in both editions, so nothing here
|
|
// changes for anybody until an operator subtracts it — which is the whole
|
|
// point of the key: a hosted fleet shares one parent domain and one
|
|
// sending reputation, and a tenant switching its own CAPTCHA off spends
|
|
// the rest of the fleet's.
|
|
test('the screen is open by default in both editions', function () {
|
|
foreach ([Edition::Community, Edition::Cloud] as $edition) {
|
|
config()->set('projectsend.edition', $edition);
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertOk();
|
|
}
|
|
});
|
|
|
|
test('withdrawing the capability closes the screen', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
|
|
|
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertNotFound();
|
|
});
|
|
|
|
// The half that actually protects the fleet. Closing the read alone would
|
|
// leave a hand-crafted PATCH able to do the damage, and turning the
|
|
// CAPTCHA off needs none of the fields the controller gates per field —
|
|
// `provider: none` does it, and so does unticking the four form switches
|
|
// while leaving perfectly good keys in place.
|
|
test('withdrawing the capability closes the write, keys or no keys', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
|
|
|
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
|
|
|
|
$this->actingAs($this->admin)
|
|
->patch('/system/settings/captcha', captchaPayload(['provider' => 'none']))
|
|
->assertNotFound();
|
|
|
|
$this->actingAs($this->admin)
|
|
->patch('/system/settings/captcha', captchaPayload([
|
|
'provider' => 'turnstile',
|
|
'site_key' => 'site-abc',
|
|
'secret_key' => 'secret-abc',
|
|
'on_login' => false,
|
|
'on_registration' => false,
|
|
'on_password_reset' => false,
|
|
'on_public_comments' => false,
|
|
]))
|
|
->assertNotFound();
|
|
|
|
$settings = app(Settings::class);
|
|
|
|
expect($settings->get(Setting::CaptchaProvider))->toBe('turnstile')
|
|
->and($settings->get(Setting::CaptchaOnLogin))->toBeTrue()
|
|
->and($settings->get(Setting::CaptchaOnRegistration))->toBeTrue()
|
|
->and($settings->get(Setting::CaptchaOnPasswordReset))->toBeTrue()
|
|
->and($settings->get(Setting::CaptchaOnPublicComments))->toBeTrue();
|
|
});
|
|
|
|
test('withdrawing the capability closes the test button too', function () {
|
|
config()->set('projectsend.edition', Edition::Cloud);
|
|
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
|
|
|
|
$this->actingAs($this->admin)
|
|
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'secret-abc'])
|
|
->assertNotFound();
|
|
});
|