diff --git a/.env.example b/.env.example index 36a800a76..a75f5ffa8 100644 --- a/.env.example +++ b/.env.example @@ -108,13 +108,15 @@ CAPTCHA_SITEKEY= CAPTCHA_TURNSTILE_SITEKEY= CAPTCHA_TURNSTILE_SECRET= CAPTCHA_TURNSTILE_TIMEOUT=5 -# Let requests through on network/5xx errors instead of blocking CAPTCHA_TURNSTILE_FAIL_OPEN=false # --- Cap, self-hosted proof-of-work (driver: cap) --- -# Full URL of your Cap instance including the site key (trailing slash required) +# Base URL WITHOUT the site key CAP_ENDPOINT= +CAP_SITEKEY= CAP_SECRET= +CAP_TOKEN_FIELD=cap-captcha-response CAP_TIMEOUT=5 CAP_FAIL_OPEN=false +# @cap.js/widget version from jsDelivr; leave "latest" to track newest stable CAP_WIDGET_VERSION=latest diff --git a/app/Http/Controllers/Admin/AdminSettingsController.php b/app/Http/Controllers/Admin/AdminSettingsController.php index d47f2c68e..94289cb7f 100644 --- a/app/Http/Controllers/Admin/AdminSettingsController.php +++ b/app/Http/Controllers/Admin/AdminSettingsController.php @@ -733,11 +733,14 @@ trait AdminSettingsController ConfigCacheService::put('captcha.turnstile.sitekey', $request->input('captcha_turnstile_sitekey')); } - // Cap credentials (endpoint is public, store as-is when present) + // Cap credentials (endpoint + sitekey are public, store as-is) $putIfChanged('captcha.cap.secret', $request->input('captcha_cap_secret')); if ($request->filled('captcha_cap_endpoint')) { ConfigCacheService::put('captcha.cap.endpoint', $request->input('captcha_cap_endpoint')); } + if ($request->filled('captcha_cap_sitekey')) { + ConfigCacheService::put('captcha.cap.sitekey', $request->input('captcha_cap_sitekey')); + } ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login')); ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register')); @@ -768,6 +771,7 @@ trait AdminSettingsController 'captcha_turnstile_secret' => $request->input('captcha_turnstile_secret'), 'captcha_turnstile_sitekey' => $request->input('captcha_turnstile_sitekey'), 'captcha_cap_endpoint' => $request->input('captcha_cap_endpoint'), + 'captcha_cap_sitekey' => $request->input('captcha_cap_sitekey'), 'captcha_cap_secret' => $request->input('captcha_cap_secret'), 'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'), ]; diff --git a/app/Services/AdminSettingsService.php b/app/Services/AdminSettingsService.php index 1305b4f96..6476bd97a 100644 --- a/app/Services/AdminSettingsService.php +++ b/app/Services/AdminSettingsService.php @@ -142,6 +142,7 @@ class AdminSettingsService 'captcha_turnstile_secret' => self::maskSecret(config_cache('captcha.turnstile.secret')), 'captcha_turnstile_sitekey' => config_cache('captcha.turnstile.sitekey'), 'captcha_cap_endpoint' => config_cache('captcha.cap.endpoint'), + 'captcha_cap_sitekey' => config_cache('captcha.cap.sitekey'), 'captcha_cap_secret' => self::maskSecret(config_cache('captcha.cap.secret')), 'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'), ]; diff --git a/app/Services/Captcha/CapDriver.php b/app/Services/Captcha/CapDriver.php index c46ea8919..998f148b9 100644 --- a/app/Services/Captcha/CapDriver.php +++ b/app/Services/Captcha/CapDriver.php @@ -10,7 +10,12 @@ use LaravelCap\Cap; * Cap driver (self-hosted proof-of-work CAPTCHA). * * Wraps the oliweb/laravel-cap package for verification, and renders the - * locally-published widget (public/vendor/cap/) so no external CDN is used. + * @cap.js/widget from the jsDelivr CDN. + * + * The full API endpoint the widget and verifier talk to is composed from a base + * URL (captcha.cap.endpoint) plus the site key (captcha.cap.sitekey): + * + * https://cap.example.com + 3c87a0e810 => https://cap.example.com/3c87a0e810/ * * @see https://github.com/oliweb-ch/laravel-cap */ @@ -30,6 +35,7 @@ class CapDriver implements CaptchaDriver public function isConfigured(): bool { return ! empty(config_cache('captcha.cap.endpoint')) + && ! empty(config_cache('captcha.cap.sitekey')) && ! empty(config_cache('captcha.cap.secret')); } @@ -38,6 +44,24 @@ class CapDriver implements CaptchaDriver return (string) config('captcha.cap.token_field', 'cap-token'); } + /** + * Compose the full Cap API endpoint: "{base}/{sitekey}/". + * + * The base URL is the instance origin without the site key. The site key is + * appended as a path segment with a trailing slash (required by Cap). + */ + public function apiEndpoint(): string + { + $base = rtrim(trim((string) config_cache('captcha.cap.endpoint')), '/'); + $sitekey = trim((string) config_cache('captcha.cap.sitekey'), '/ '); + + if ($base === '' || $sitekey === '') { + return ''; + } + + return $base.'/'.$sitekey.'/'; + } + public function verify(array $input): bool { $token = $input[$this->responseField()] ?? null; @@ -46,8 +70,13 @@ class CapDriver implements CaptchaDriver return false; } + $endpoint = $this->apiEndpoint(); + if ($endpoint === '') { + return false; + } + $cap = new Cap(app(HttpFactory::class), [ - 'endpoint' => config_cache('captcha.cap.endpoint'), + 'endpoint' => $endpoint, 'secret' => config_cache('captcha.cap.secret'), 'timeout' => (int) config('captcha.cap.timeout', 5), 'fail_open' => (bool) config('captcha.cap.fail_open', false), @@ -58,7 +87,7 @@ class CapDriver implements CaptchaDriver public function render(array $attributes = []): string { - $endpoint = e((string) config_cache('captcha.cap.endpoint')); + $endpoint = e($this->apiEndpoint()); $field = e($this->responseField()); $attrs = ''; diff --git a/app/Services/ConfigCacheService.php b/app/Services/ConfigCacheService.php index a12039030..5c6f95971 100644 --- a/app/Services/ConfigCacheService.php +++ b/app/Services/ConfigCacheService.php @@ -115,6 +115,7 @@ class ConfigCacheService 'captcha.turnstile.secret', 'captcha.turnstile.sitekey', 'captcha.cap.endpoint', + 'captcha.cap.sitekey', 'captcha.cap.secret', 'captcha.active.login', 'captcha.active.register', diff --git a/config/captcha.php b/config/captcha.php index ec5750c22..1f8cb0966 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -63,11 +63,13 @@ return [ |-------------------------------------------------------------------------- | Cap (self-hosted proof-of-work CAPTCHA) |-------------------------------------------------------------------------- - | The endpoint must include the site key and a trailing slash, e.g. - | https://cap.example.com/your-site-key/ + | The endpoint is the instance base URL WITHOUT the site key, e.g. + | https://cap.example.com. The site key is a separate value; the full API + | endpoint (https://cap.example.com/your-site-key/) is composed by CapDriver. */ 'cap' => [ 'endpoint' => env('CAP_ENDPOINT'), + 'sitekey' => env('CAP_SITEKEY'), 'secret' => env('CAP_SECRET'), 'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'), 'timeout' => (int) env('CAP_TIMEOUT', 5), diff --git a/resources/assets/components/admin/AdminSettings.vue b/resources/assets/components/admin/AdminSettings.vue index b8f5c238a..76b27832d 100644 --- a/resources/assets/components/admin/AdminSettings.vue +++ b/resources/assets/components/admin/AdminSettings.vue @@ -460,15 +460,26 @@
-
+
+ Base URL of your Cap instance, without the site key. +
+
+
+
+ +
@@ -1496,6 +1507,7 @@ captcha_turnstile_secret: this.platform.captcha_turnstile_secret, captcha_turnstile_sitekey: this.platform.captcha_turnstile_sitekey, captcha_cap_endpoint: this.platform.captcha_cap_endpoint, + captcha_cap_sitekey: this.platform.captcha_cap_sitekey, captcha_cap_secret: this.platform.captcha_cap_secret, captcha_on_login: this.platform.captcha_on_login, captcha_on_register: this.platform.captcha_on_register, diff --git a/tests/Feature/CaptchaAdminSettingsTest.php b/tests/Feature/CaptchaAdminSettingsTest.php new file mode 100644 index 000000000..7b54bd97e --- /dev/null +++ b/tests/Feature/CaptchaAdminSettingsTest.php @@ -0,0 +1,92 @@ + true, + 'captcha.driver' => 'turnstile', + 'captcha.active.login' => true, + 'captcha.active.register' => false, + 'captcha.active.forgotpassword' => true, + 'captcha.active.password_reset' => false, + 'captcha.active.curated_register' => true, + ]); + + $platform = AdminSettingsService::getPlatform(); + + $this->assertTrue($platform['captcha_enabled']); + $this->assertSame('turnstile', $platform['captcha_driver']); + $this->assertTrue($platform['captcha_on_login']); + $this->assertFalse($platform['captcha_on_register']); + $this->assertTrue($platform['captcha_on_forgotpassword']); + $this->assertFalse($platform['captcha_on_password_reset']); + $this->assertTrue($platform['captcha_on_curated_register']); + } + + #[Test] + public function platform_settings_expose_cap_endpoint_and_sitekey(): void + { + config([ + 'captcha.cap.endpoint' => 'https://cap.example.com', + 'captcha.cap.sitekey' => '3c87a0e810', + ]); + + $platform = AdminSettingsService::getPlatform(); + + $this->assertArrayHasKey('captcha_cap_endpoint', $platform); + $this->assertArrayHasKey('captcha_cap_sitekey', $platform); + $this->assertSame('https://cap.example.com', $platform['captcha_cap_endpoint']); + $this->assertSame('3c87a0e810', $platform['captcha_cap_sitekey']); + } + + #[Test] + public function secrets_are_masked_and_sitekeys_are_not(): void + { + config([ + 'captcha.hcaptcha.secret' => 'supersecretvalue', + 'captcha.hcaptcha.sitekey' => 'publicsitekey123', + 'captcha.turnstile.secret' => 'turnstilesecretvalue', + 'captcha.turnstile.sitekey' => '0xPUBLICKEY', + 'captcha.cap.secret' => 'capsecretvalue', + ]); + + $platform = AdminSettingsService::getPlatform(); + + // Secrets masked (contain the mask char, not the raw value). + $this->assertStringContainsString('*', $platform['captcha_hcaptcha_secret']); + $this->assertStringNotContainsString('supersecretvalue', $platform['captcha_hcaptcha_secret']); + $this->assertStringContainsString('*', $platform['captcha_turnstile_secret']); + $this->assertStringContainsString('*', $platform['captcha_cap_secret']); + + // Public keys returned as-is (turnstile sitekey is not masked). + $this->assertSame('0xPUBLICKEY', $platform['captcha_turnstile_sitekey']); + } + + #[Test] + public function mask_secret_tolerates_empty_and_null_values(): void + { + config([ + 'captcha.hcaptcha.secret' => null, + 'captcha.turnstile.secret' => '', + ]); + + // Should not throw even when secrets are null/empty. + $platform = AdminSettingsService::getPlatform(); + + $this->assertArrayHasKey('captcha_hcaptcha_secret', $platform); + $this->assertArrayHasKey('captcha_turnstile_secret', $platform); + } +} diff --git a/tests/Feature/CaptchaManagerTest.php b/tests/Feature/CaptchaManagerTest.php index 253e0c9f9..d42132aad 100644 --- a/tests/Feature/CaptchaManagerTest.php +++ b/tests/Feature/CaptchaManagerTest.php @@ -6,6 +6,7 @@ use App\Services\Captcha\CapDriver; use App\Services\Captcha\CaptchaManager; use App\Services\Captcha\HCaptchaDriver; use App\Services\Captcha\TurnstileDriver; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -18,6 +19,10 @@ class CaptchaManagerTest extends TestCase return new CaptchaManager($this->app); } + // --------------------------------------------------------------------- + // Driver resolution + // --------------------------------------------------------------------- + #[Test] public function it_resolves_the_hcaptcha_driver_by_default(): void { @@ -30,25 +35,6 @@ class CaptchaManagerTest extends TestCase $this->assertSame('h-captcha-response', $driver->responseField()); } - #[Test] - public function hcaptcha_is_configured_from_namespaced_keys(): void - { - config([ - 'captcha.driver' => 'hcaptcha', - 'captcha.hcaptcha.secret' => 'a-real-secret', - 'captcha.hcaptcha.sitekey' => 'a-real-sitekey', - ]); - - $this->assertTrue($this->manager()->active()->isConfigured()); - - // Placeholder defaults should read as not configured. - config([ - 'captcha.hcaptcha.secret' => 'default_secret', - 'captcha.hcaptcha.sitekey' => 'default_sitekey', - ]); - $this->assertFalse($this->manager()->active()->isConfigured()); - } - #[Test] public function it_resolves_the_turnstile_driver(): void { @@ -73,12 +59,65 @@ class CaptchaManagerTest extends TestCase $this->assertSame('cap-token', $driver->responseField()); } + #[Test] + public function it_falls_back_to_hcaptcha_for_unknown_or_empty_driver(): void + { + config(['captcha.driver' => null]); + $this->assertInstanceOf(HCaptchaDriver::class, $this->manager()->active()); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_resolves_each_driver_and_field(string $driver, string $class, string $field): void + { + config(['captcha.driver' => $driver]); + + $resolved = $this->manager()->active(); + + $this->assertInstanceOf($class, $resolved); + $this->assertSame($driver, $resolved->name()); + $this->assertSame($field, $resolved->responseField()); + } + + public static function driverProvider(): array + { + return [ + 'hcaptcha' => ['hcaptcha', HCaptchaDriver::class, 'h-captcha-response'], + 'turnstile' => ['turnstile', TurnstileDriver::class, 'cf-turnstile-response'], + 'cap' => ['cap', CapDriver::class, 'cap-token'], + ]; + } + #[Test] public function it_lists_available_drivers(): void { $this->assertSame(['hcaptcha', 'turnstile', 'cap'], $this->manager()->available()); } + #[Test] + public function rules_use_the_active_drivers_response_field(): void + { + config(['captcha.driver' => 'cap', 'captcha.cap.token_field' => 'cap-token']); + $this->assertSame(['cap-token' => 'required|captcha_verify'], $this->manager()->rules()); + + config(['captcha.driver' => 'turnstile']); + $this->assertArrayHasKey('cf-turnstile-response', $this->manager()->rules()); + } + + // --------------------------------------------------------------------- + // enabled() / activeOn() + // --------------------------------------------------------------------- + + #[Test] + public function enabled_reflects_the_global_toggle(): void + { + config(['captcha.enabled' => true]); + $this->assertTrue($this->manager()->enabled()); + + config(['captcha.enabled' => false]); + $this->assertFalse($this->manager()->enabled()); + } + #[Test] public function active_on_requires_the_global_toggle(): void { @@ -112,41 +151,56 @@ class CaptchaManagerTest extends TestCase } #[Test] - public function cap_widget_defaults_to_latest_when_no_version_set(): void + public function active_on_returns_false_for_an_unknown_surface(): void + { + config(['captcha.enabled' => true]); + $this->assertFalse($this->manager()->activeOn('does_not_exist')); + } + + // --------------------------------------------------------------------- + // hCaptcha driver + // --------------------------------------------------------------------- + + #[Test] + public function hcaptcha_is_configured_from_namespaced_keys(): void { config([ - 'captcha.driver' => 'cap', - 'captcha.cap.endpoint' => 'https://cap.example.com/site-key/', - 'captcha.cap.widget_version' => null, + 'captcha.driver' => 'hcaptcha', + 'captcha.hcaptcha.secret' => 'a-real-secret', + 'captcha.hcaptcha.sitekey' => 'a-real-sitekey', ]); - $scripts = $this->manager()->active()->scripts(); - - $this->assertStringContainsString('@cap.js/widget@latest', $scripts); + $this->assertTrue($this->manager()->active()->isConfigured()); } #[Test] - public function cap_widget_loads_from_cdn_with_pinned_version(): void + public function hcaptcha_is_not_configured_with_placeholder_defaults(): void { config([ - 'captcha.driver' => 'cap', - 'captcha.cap.endpoint' => 'https://cap.example.com/site-key/', - 'captcha.cap.widget_version' => '0.1.57', + 'captcha.driver' => 'hcaptcha', + 'captcha.hcaptcha.secret' => 'default_secret', + 'captcha.hcaptcha.sitekey' => 'default_sitekey', ]); - $driver = $this->manager()->active(); - $scripts = $driver->scripts(); + $this->assertFalse($this->manager()->active()->isConfigured()); + } - $this->assertStringContainsString('cdn.jsdelivr.net/npm/@cap.js/widget@0.1.57', $scripts); - // Must not reference self-hosted assets anymore. - $this->assertStringNotContainsString('vendor/cap/', $scripts); + #[Test] + public function hcaptcha_is_not_configured_when_keys_are_empty(): void + { + config([ + 'captcha.driver' => 'hcaptcha', + 'captcha.hcaptcha.secret' => '', + 'captcha.hcaptcha.sitekey' => '', + ]); - $markup = $driver->render(['data-theme' => 'dark']); - $this->assertStringContainsString('assertStringContainsString('data-cap-api-endpoint="https://cap.example.com/site-key/"', $markup); - $this->assertStringContainsString('data-cap-hidden-field-name="cap-token"', $markup); + $this->assertFalse($this->manager()->active()->isConfigured()); } + // --------------------------------------------------------------------- + // Turnstile driver + // --------------------------------------------------------------------- + #[Test] public function turnstile_renders_widget_with_sitekey(): void { @@ -160,4 +214,188 @@ class CaptchaManagerTest extends TestCase $this->assertStringContainsString('cf-turnstile', $markup); $this->assertStringContainsString('data-sitekey="0xTESTSITEKEY"', $markup); } + + #[Test] + public function turnstile_render_escapes_attributes(): void + { + config([ + 'captcha.driver' => 'turnstile', + 'captcha.turnstile.sitekey' => '0xKEY', + ]); + + $markup = $this->manager()->active()->render(['data-theme' => '">