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 Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ RUN apt-get update \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& docker-php-ext-install intl pdo_mysql zip \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& a2enmod rewrite headers expires \
&& sed -ri "s!/var/www/html!${APACHE_DOCUMENT_ROOT}!g" /etc/apache2/sites-available/000-default.conf /etc/apache2/apache2.conf \
&& rm -rf /var/lib/apt/lists/*
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

This repository contains the code responsible for https://plugins.cakephp.org

## Production request protection

The public package listing has query validation and an IP-based rate limit. It
uses Dokku's `REDIS_URL` by default. `RATE_LIMIT_CACHE_URL` can override it if
you later want a dedicated Redis database or service.

The container includes the PHP Redis extension. `TRUSTED_PROXY_IPS` must list
only proxies that overwrite `X-Forwarded-For`; for the current Dokku setup its
default is `172.17.0.1`. Set `RATE_LIMIT_ENABLED=false` only for temporary
maintenance or local development. The defaults are 90 listing views/minute,
15 filtered listings/minute, and 30 autocomplete requests/minute per client IP.

## Starting local development

You need [DDEV](https://docs.ddev.com/en/stable/) installed and configured on your machine.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"illuminate": "bin/cake illuminate code",
"phpstan": "phpstan analyse",
"phpstan-baseline": "phpstan --generate-baseline",
"rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.3.1\" && mv composer.backup composer.json",
"rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.6.1\" && mv composer.backup composer.json",
"rector-check": "vendor/bin/rector process --dry-run",
"rector-fix": "vendor/bin/rector process"
}
Expand Down
62 changes: 61 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions config/app.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
<?php

declare(strict_types=1);

use Cake\Cache\Engine\FileEngine;
use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Cake\Log\Engine\FileLog;
use Cake\Mailer\Transport\MailTransport;
use function Cake\Core\env;

$rateLimitCacheUrl = env('RATE_LIMIT_CACHE_URL', env('REDIS_URL'));
$rateLimitEnabled = filter_var(
env('RATE_LIMIT_ENABLED', $rateLimitCacheUrl !== null),
FILTER_VALIDATE_BOOL,
);
$trustedProxyIps = array_values(array_filter(array_map(
trim(...),
explode(',', (string)env('TRUSTED_PROXY_IPS', '172.17.0.1')),
)));

return [
/*
* Debug Level:
Expand Down Expand Up @@ -112,6 +124,23 @@
],
],

/*
* Protection for the public package listing and autocomplete endpoints.
*
* RATE_LIMIT_CACHE_URL must be a redis:// DSN. The rate limiter remains
* disabled until that shared, atomic cache has been configured.
*/
'PublicRequestProtection' => [
'rateLimitEnabled' => $rateLimitEnabled && is_string($rateLimitCacheUrl) && str_starts_with($rateLimitCacheUrl, 'redis://'),
'trustedProxyIps' => $trustedProxyIps,
'maxFilterValues' => max(1, (int)env('MAX_PACKAGE_FILTER_VALUES', 3)),
'rateLimits' => [
'browse' => ['limit' => 90, 'window' => 60],
'filtered' => ['limit' => 15, 'window' => 60],
'autocomplete' => ['limit' => 30, 'window' => 60],
],
],

/*
* Configure the cache adapters.
*/
Expand All @@ -122,6 +151,14 @@
'url' => env('CACHE_DEFAULT_URL'),
],

'rate_limit' => [
'className' => FileEngine::class,
'path' => CACHE . 'rate_limit' . DS,
'duration' => '+1 minute',
'prefix' => 'plugins_rate_limit_',
'fallback' => false,
] + ($rateLimitCacheUrl ? ['url' => $rateLimitCacheUrl] : []),

/*
* Configure the cache used for general framework caching.
* Translation cache files are stored with this configuration.
Expand Down
2 changes: 2 additions & 0 deletions config/app_local.example.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

use function Cake\Core\env;

/*
Expand Down
2 changes: 2 additions & 0 deletions config/paths.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
Expand Down
20 changes: 11 additions & 9 deletions config/plugins.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<?php

declare(strict_types=1);

/**
* Plugin configuration.
*
Expand All @@ -18,15 +21,14 @@
* @since 5.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

/*
* List of plugins to load in the form `PluginName` => `[configuration options]`.
*
* Available options:
* - onlyDebug: Load the plugin only in debug mode. Default false.
* - onlyCli: Load the plugin only in CLI mode. Default false.
* - optional: Do not throw an exception if the plugin is not found. Default false.
*/
/*
* List of plugins to load in the form `PluginName` => `[configuration options]`.
*
* Available options:
* - onlyDebug: Load the plugin only in debug mode. Default false.
* - onlyCli: Load the plugin only in CLI mode. Default false.
* - optional: Do not throw an exception if the plugin is not found. Default false.
*/
return [
'DebugKit' => ['onlyDebug' => true],
'Bake' => ['onlyCli' => true, 'optional' => true],
Expand Down
2 changes: 2 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);

/**
* Routes configuration.
*
Expand Down
1 change: 0 additions & 1 deletion rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
CompactToVariablesRector::class,
SplitDoubleAssignRector::class,
ChangeOrIfContinueToMultiContinueRector::class,
ExplicitBoolCompareRector::class,
NewlineBeforeNewAssignSetRector::class,
DisallowedEmptyRuleFixerRector::class,
RemoveUselessParamTagRector::class,
Expand Down
61 changes: 59 additions & 2 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

use ADmad\SocialAuth\Middleware\SocialAuthMiddleware;
use App\Event\AfterGithubIdentify;
use App\Http\Middleware\PublicRequestGuardMiddleware;
use App\Http\RateLimit\AtomicFixedWindowRateLimiter;
use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
Expand All @@ -30,6 +32,7 @@
use Cake\Http\BaseApplication;
use Cake\Http\Middleware\BodyParserMiddleware;
use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Cake\Http\Middleware\RateLimitMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\Http\ServerRequest;
use Cake\ORM\Locator\TableLocator;
Expand All @@ -43,8 +46,6 @@
*
* This defines the bootstrapping logic and middleware layers you
* want to use in your application.
*
* @extends \Cake\Http\BaseApplication<\App\Application>
*/
class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
Expand Down Expand Up @@ -87,6 +88,8 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
return true;
}
});
$requestProtection = (array)Configure::read('PublicRequestProtection', []);
$trustedProxyIps = (array)($requestProtection['trustedProxyIps'] ?? []);

$middlewareQueue
// Catch any exceptions in the lower layers,
Expand All @@ -98,6 +101,12 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
'cacheTime' => Configure::read('Asset.cacheTime'),
]))

// Reject malformed and unnecessarily expensive public search requests
// before routing or database work is performed.
->add(new PublicRequestGuardMiddleware([
'maxFilterValues' => (int)($requestProtection['maxFilterValues'] ?? 3),
]))

// Add routing middleware.
// If you have a large number of routes connected, turning on routes
// caching in production could improve performance.
Expand Down Expand Up @@ -137,6 +146,54 @@ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue

->add(new AuthenticationMiddleware($this));

if ($requestProtection['rateLimitEnabled'] ?? false) {
$middlewareQueue->insertAfter(
PublicRequestGuardMiddleware::class,
new RateLimitMiddleware([
'cache' => 'rate_limit',
'headers' => true,
'strategyClass' => AtomicFixedWindowRateLimiter::class,
'identifierCallback' => static function (ServerRequestInterface $request) use ($trustedProxyIps): string {
$remoteAddress = (string)($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
if (!in_array($remoteAddress, $trustedProxyIps, true)) {
return $remoteAddress;
}

$forwardedFor = trim(explode(',', $request->getHeaderLine('X-Forwarded-For'))[0] ?? '');
if (filter_var($forwardedFor, FILTER_VALIDATE_IP) === false) {
return $remoteAddress;
}

return $forwardedFor;
},
'limiterResolver' => static function (ServerRequestInterface $request): string {
$path = rtrim($request->getUri()->getPath(), '/');
if (str_ends_with($path, '.json')) {
$path = substr($path, 0, -5);
}
if ($path === '/autocomplete') {
return 'autocomplete';
}

return $request->getQueryParams() === [] ? 'browse' : 'filtered';
},
'limiters' => $requestProtection['rateLimits'] ?? [],
'skipCheck' => static function (ServerRequestInterface $request): bool {
if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
return true;
}

$path = rtrim($request->getUri()->getPath(), '/');
if (str_ends_with($path, '.json')) {
$path = substr($path, 0, -5);
}

return !in_array($path, ['', '/packages', '/autocomplete'], true);
},
]),
);
}

return $middlewareQueue;
}

Expand Down
3 changes: 3 additions & 0 deletions src/Command/CleanCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace App\Command;

use Cake\Cache\Cache;
use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
Expand Down Expand Up @@ -61,5 +62,7 @@ public function execute(Arguments $args, ConsoleIo $io): void
foreach ($allPackages as $package) {
$packagesTable->delete($package);
}

Cache::delete('package_filter_tags_v1');
}
}
7 changes: 5 additions & 2 deletions src/Command/SyncPackagesCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace App\Command;

use Cake\Cache\Cache;
use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\CommandFactoryInterface;
Expand Down Expand Up @@ -123,7 +124,7 @@ public function execute(Arguments $args, ConsoleIo $io): void
$failed = 0;
$i = 0;

/** @var \Cake\Command\Helper\ProgressHelper $progress */
/** @var \Cake\Console\Helper\ProgressHelper $progress */
$progress = $io->helper('Progress');
$progress->init(['total' => $total, 'width' => 60]);
$io->out('', 0);
Expand Down Expand Up @@ -195,6 +196,8 @@ public function execute(Arguments $args, ConsoleIo $io): void
$deleted,
$deleteFailed,
));

Cache::delete('package_filter_tags_v1');
}

/**
Expand Down Expand Up @@ -267,7 +270,7 @@ private function getDataForPackage(string $packageName): array
}

/**
* @return \Cake\I18n\Date|null
* Extract the release date
*/
private function extractReleaseDate(?Version $version): ?Date
{
Expand Down
Loading
Loading