Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ MAIL_DRIVER=array
QUEUE_DRIVER=sync

HASHIDS_SALT=test123

CAPTCHA_PROVIDER=none
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,20 @@ MAIL_FROM_NAME="Pterodactyl Panel"
#
# @see: https://github.com/pterodactyl/panel/pull/3110
# MAIL_EHLO_DOMAIN=panel.example.com

##
# CAPTCHA Configuration
# Supported providers: recaptcha, turnstile, none
# - recaptcha: Google reCAPTCHA v2 (invisible)
# - turnstile: Cloudflare Turnstile (no mobile QR-code verification)
# - none: Disable CAPTCHA entirely (not recommended for production)
##
CAPTCHA_PROVIDER=recaptcha

# Google reCAPTCHA keys (used when CAPTCHA_PROVIDER=recaptcha)
# RECAPTCHA_SECRET_KEY=
# RECAPTCHA_WEBSITE_KEY=

# Cloudflare Turnstile keys (used when CAPTCHA_PROVIDER=turnstile)
# TURNSTILE_SECRET_KEY=
# TURNSTILE_WEBSITE_KEY=
8 changes: 6 additions & 2 deletions app/Http/Controllers/Admin/Settings/AdvancedController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ public function __construct(
public function index(): View
{
$showRecaptchaWarning = false;
$provider = $this->config->get('captcha.provider', 'none');

if (
$this->config->get('recaptcha._shipped_secret_key') === $this->config->get('recaptcha.secret_key')
|| $this->config->get('recaptcha._shipped_website_key') === $this->config->get('recaptcha.website_key')
$provider === 'recaptcha' && (
$this->config->get('captcha.recaptcha._shipped_secret_key') === $this->config->get('captcha.recaptcha.secret_key')
|| $this->config->get('captcha.recaptcha._shipped_website_key') === $this->config->get('captcha.recaptcha.website_key')
)
) {
$showRecaptchaWarning = true;
}
Expand Down
40 changes: 40 additions & 0 deletions app/Http/Middleware/VerifyCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Pterodactyl\Http\Middleware;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Events\Auth\FailedCaptcha;
use Illuminate\Contracts\Events\Dispatcher;
use Pterodactyl\Services\Captcha\CaptchaVerificationService;
use Symfony\Component\HttpKernel\Exception\HttpException;

class VerifyCaptcha
{
public function __construct(private Dispatcher $dispatcher, private CaptchaVerificationService $captcha)
{
}

/**
* Handle an incoming request.
*/
public function handle(Request $request, \Closure $next): mixed
{
if (!$this->captcha->isEnabled()) {
return $next($request);
}

if ($this->captcha->verify($request)) {
return $next($request);
}

$this->dispatcher->dispatch(
new FailedCaptcha($request->ip(), $request->getHost())
);

throw new HttpException(
Response::HTTP_BAD_REQUEST,
'Failed to validate CAPTCHA data.'
);
}
}
71 changes: 5 additions & 66 deletions app/Http/Middleware/VerifyReCaptcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,10 @@

namespace Pterodactyl\Http\Middleware;

use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Events\Auth\FailedCaptcha;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Events\Dispatcher;
use Symfony\Component\HttpKernel\Exception\HttpException;

class VerifyReCaptcha
/**
* @deprecated Use VerifyCaptcha instead. Kept for backward compatibility with
* existing route middleware alias 'recaptcha'.
*/
class VerifyReCaptcha extends VerifyCaptcha
{
/**
* VerifyReCaptcha constructor.
*/
public function __construct(private Dispatcher $dispatcher, private Repository $config)
{
}

/**
* Handle an incoming request.
*/
public function handle(Request $request, \Closure $next): mixed
{
if (!$this->config->get('recaptcha.enabled')) {
return $next($request);
}

if ($request->filled('g-recaptcha-response')) {
$client = new Client();
$res = $client->post($this->config->get('recaptcha.domain'), [
'form_params' => [
'secret' => $this->config->get('recaptcha.secret_key'),
'response' => $request->input('g-recaptcha-response'),
],
]);

if ($res->getStatusCode() === 200) {
$result = json_decode($res->getBody());

if ($result->success && (!$this->config->get('recaptcha.verify_domain') || $this->isResponseVerified($result, $request))) {
return $next($request);
}
}
}

$this->dispatcher->dispatch(
new FailedCaptcha(
$request->ip(),
!empty($result) ? ($result->hostname ?? null) : null
)
);

throw new HttpException(Response::HTTP_BAD_REQUEST, 'Failed to validate reCAPTCHA data.');
}

/**
* Determine if the response from the recaptcha servers was valid.
*/
private function isResponseVerified(\stdClass $result, Request $request): bool
{
if (!$this->config->get('recaptcha.verify_domain')) {
return false;
}

$url = parse_url($request->url());

return $result->hostname === array_get($url, 'host');
}
}
16 changes: 10 additions & 6 deletions app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ class AdvancedSettingsFormRequest extends AdminFormRequest
public function rules(): array
{
return [
'recaptcha:enabled' => 'required|in:true,false',
'recaptcha:secret_key' => 'required|string|max:191',
'recaptcha:website_key' => 'required|string|max:191',
'captcha:provider' => 'required|in:recaptcha,turnstile,none',
'captcha:recaptcha:secret_key' => 'required_if:captcha:provider,recaptcha|nullable|string|max:191',
'captcha:recaptcha:website_key' => 'required_if:captcha:provider,recaptcha|nullable|string|max:191',
'captcha:turnstile:secret_key' => 'required_if:captcha:provider,turnstile|nullable|string|max:191',
'captcha:turnstile:website_key' => 'required_if:captcha:provider,turnstile|nullable|string|max:191',
'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60',
'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60',
'pterodactyl:client_features:allocations:enabled' => 'required|in:true,false',
Expand All @@ -37,9 +39,11 @@ public function rules(): array
public function attributes(): array
{
return [
'recaptcha:enabled' => 'reCAPTCHA Enabled',
'recaptcha:secret_key' => 'reCAPTCHA Secret Key',
'recaptcha:website_key' => 'reCAPTCHA Website Key',
'captcha:provider' => 'CAPTCHA Provider',
'captcha:recaptcha:secret_key' => 'reCAPTCHA Secret Key',
'captcha:recaptcha:website_key' => 'reCAPTCHA Website Key',
'captcha:turnstile:secret_key' => 'Turnstile Secret Key',
'captcha:turnstile:website_key' => 'Turnstile Website Key',
'pterodactyl:guzzle:timeout' => 'HTTP Request Timeout',
'pterodactyl:guzzle:connect_timeout' => 'HTTP Connection Timeout',
'pterodactyl:client_features:allocations:enabled' => 'Auto Create Allocations Enabled',
Expand Down
14 changes: 12 additions & 2 deletions app/Http/ViewComposers/AssetComposer.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,23 @@ public function __construct(private AssetHashService $assetHashService)
*/
public function compose(View $view): void
{
$provider = config('captcha.provider', 'none');

$view->with('asset', $this->assetHashService);
$view->with('siteConfiguration', [
'name' => config('app.name') ?? 'Pterodactyl',
'locale' => config('app.locale') ?? 'en',
'recaptcha' => [
'enabled' => config('recaptcha.enabled', false),
'siteKey' => config('recaptcha.website_key') ?? '',
'enabled' => $provider === 'recaptcha',
'siteKey' => config('captcha.recaptcha.website_key') ?? '',
],
'captcha' => [
'provider' => $provider,
'siteKey' => match ($provider) {
'recaptcha' => config('captcha.recaptcha.website_key') ?? '',
'turnstile' => config('captcha.turnstile.website_key') ?? '',
default => '',
},
],
]);
}
Expand Down
119 changes: 119 additions & 0 deletions app/Services/Captcha/CaptchaVerificationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Pterodactyl\Services\Captcha;

use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Contracts\Config\Repository;

class CaptchaVerificationService
{
public function __construct(private Repository $config, private Client $client)
{
}

/**
* Determine if CAPTCHA verification is enabled.
*/
public function isEnabled(): bool
{
return $this->getProvider() !== 'none';
}

/**
* Get the configured CAPTCHA provider name.
*/
public function getProvider(): string
{
return $this->config->get('captcha.provider', 'none');
}

/**
* Verify the CAPTCHA response token for the configured provider.
*/
public function verify(Request $request): bool
{
$provider = $this->getProvider();

return match ($provider) {
'recaptcha' => $this->verifyRecaptcha($request),
'turnstile' => $this->verifyTurnstile($request),
default => true,
};
}

/**
* Get the request field name that contains the CAPTCHA response token.
*/
public function getResponseField(): string
{
return match ($this->getProvider()) {
'turnstile' => 'cf-turnstile-response',
default => 'g-recaptcha-response',
};
}

/**
* Verify a Google reCAPTCHA response.
*/
private function verifyRecaptcha(Request $request): bool
{
$token = $request->input('g-recaptcha-response');
if (empty($token)) {
return false;
}

$res = $this->client->post($this->config->get('captcha.recaptcha.domain'), [
'form_params' => [
'secret' => $this->config->get('captcha.recaptcha.secret_key'),
'response' => $token,
],
]);

if ($res->getStatusCode() !== 200) {
return false;
}

$result = json_decode($res->getBody());

if (!$result->success) {
return false;
}

// Optionally verify the domain matches.
if ($this->config->get('captcha.recaptcha.verify_domain')) {
$url = parse_url($request->url());

return ($result->hostname ?? null) === ($url['host'] ?? null);
}

return true;
}

/**
* Verify a Cloudflare Turnstile response.
*/
private function verifyTurnstile(Request $request): bool
{
$token = $request->input('cf-turnstile-response');
if (empty($token)) {
return false;
}

$res = $this->client->post($this->config->get('captcha.turnstile.domain'), [
'form_params' => [
'secret' => $this->config->get('captcha.turnstile.secret_key'),
'response' => $token,
'remoteip' => $request->ip(),
],
]);

if ($res->getStatusCode() !== 200) {
return false;
}

$result = json_decode($res->getBody());

return $result->success ?? false;
}
}
43 changes: 43 additions & 0 deletions config/captcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

return [
/*
|--------------------------------------------------------------------------
| CAPTCHA Provider
|--------------------------------------------------------------------------
|
| Supported providers: "recaptcha", "turnstile", "none"
|
| - recaptcha: Google reCAPTCHA v2 (invisible). Existing behavior.
| - turnstile: Cloudflare Turnstile. A privacy-friendly alternative that
| does not force mobile QR-code verification.
| - none: CAPTCHA disabled entirely. Not recommended for production.
|
*/
'provider' => env('CAPTCHA_PROVIDER', env('RECAPTCHA_ENABLED', true) ? 'recaptcha' : 'none'),

/*
|--------------------------------------------------------------------------
| Google reCAPTCHA
|--------------------------------------------------------------------------
*/
'recaptcha' => [
'domain' => env('RECAPTCHA_DOMAIN', 'https://www.google.com/recaptcha/api/siteverify'),
'secret_key' => env('RECAPTCHA_SECRET_KEY', '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5'),
'_shipped_secret_key' => '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5',
'website_key' => env('RECAPTCHA_WEBSITE_KEY', '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn'),
'_shipped_website_key' => '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn',
'verify_domain' => true,
],

/*
|--------------------------------------------------------------------------
| Cloudflare Turnstile
|--------------------------------------------------------------------------
*/
'turnstile' => [
'domain' => env('TURNSTILE_DOMAIN', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
'secret_key' => env('TURNSTILE_SECRET_KEY', ''),
'website_key' => env('TURNSTILE_WEBSITE_KEY', ''),
],
];
Loading
Loading