diff --git a/src/Controller/DataProviderController.php b/src/Controller/DataProviderController.php index b8825d3b9..18b537221 100644 --- a/src/Controller/DataProviderController.php +++ b/src/Controller/DataProviderController.php @@ -25,6 +25,7 @@ use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistryInterface; use SwagMigrationAssistant\DataProvider\Service\EnvironmentServiceInterface; use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\JsonResponse; @@ -50,6 +51,7 @@ public function __construct( private readonly EntityRepository $mediaRepository, private readonly MediaService $mediaService, private readonly FilesystemOperator $privateFilesystem, + private readonly MigrationApiRateLimiter $rateLimiter, ) { } @@ -134,6 +136,8 @@ public function getTable(Request $request, Context $context): JsonResponse )] public function generateDocument(Request $request, Context $context): JsonResponse { + $this->rateLimiter->ensureDownloadAccepted($request); + $identifier = (string) $request->query->get('identifier'); if ($identifier === '') { @@ -161,6 +165,8 @@ public function generateDocument(Request $request, Context $context): JsonRespon )] public function downloadPrivateFile(Request $request, Context $context): StreamedResponse|RedirectResponse { + $this->rateLimiter->ensureDownloadAccepted($request); + $identifier = (string) $request->query->get('identifier'); if ($identifier === '') { diff --git a/src/Controller/HistoryController.php b/src/Controller/HistoryController.php index 8db42f3ff..df4e38a15 100644 --- a/src/Controller/HistoryController.php +++ b/src/Controller/HistoryController.php @@ -15,6 +15,7 @@ use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\History\HistoryServiceInterface; use SwagMigrationAssistant\Migration\History\LogGroupingService; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,6 +35,7 @@ public function __construct( private readonly HistoryServiceInterface $historyService, private readonly LogGroupingService $logGroupingService, private readonly int $maxLimit, + private readonly MigrationApiRateLimiter $rateLimiter, ) { } @@ -45,6 +47,8 @@ public function __construct( )] public function getGroupedLogsOfRun(Request $request, Context $context): JsonResponse { + $this->rateLimiter->ensureLogAccessAccepted($request); + $runUuid = $request->query->getAlnum('runUuid'); if ($runUuid === '') { @@ -75,6 +79,8 @@ public function getGroupedLogsOfRun(Request $request, Context $context): JsonRes )] public function downloadLogsOfRun(Request $request, Context $context): StreamedResponse { + $this->rateLimiter->ensureDownloadAccepted($request); + $runUuid = $request->request->getAlnum('runUuid'); if ($runUuid === '') { @@ -106,6 +112,8 @@ public function downloadLogsOfRun(Request $request, Context $context): StreamedR )] public function getLogGroups(Request $request, Context $context): JsonResponse { + $this->rateLimiter->ensureLogAccessAccepted($request); + $runId = $request->query->getAlnum('runId'); if ($runId === '') { @@ -157,6 +165,8 @@ public function getLogGroups(Request $request, Context $context): JsonResponse )] public function getUnresolvedLogsBatchInformation(Request $request): JsonResponse { + $this->rateLimiter->ensureLogAccessAccepted($request); + $runId = $request->request->getAlnum('runId'); if ($runId === '') { @@ -205,6 +215,8 @@ public function getUnresolvedLogsBatchInformation(Request $request): JsonRespons )] public function getLogEntityIdsWithoutFix(Request $request): JsonResponse { + $this->rateLimiter->ensureLogAccessAccepted($request); + $runId = $request->request->getAlnum('runId'); if ($runId === '') { diff --git a/src/DependencyInjection/CompilerPass/MigrationRateLimiterCompilerPass.php b/src/DependencyInjection/CompilerPass/MigrationRateLimiterCompilerPass.php new file mode 100644 index 000000000..348c15bce --- /dev/null +++ b/src/DependencyInjection/CompilerPass/MigrationRateLimiterCompilerPass.php @@ -0,0 +1,62 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\DependencyInjection\CompilerPass; + +use Psr\Clock\ClockInterface; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\RateLimiter\RateLimiter; +use Shopware\Core\Framework\RateLimiter\RateLimiterFactory; +use Shopware\Core\System\SystemConfig\SystemConfigService; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\RateLimiter\Storage\CacheStorage; + +#[Package('fundamentals@after-sales')] +class MigrationRateLimiterCompilerPass implements CompilerPassInterface +{ + /** + * @var array + */ + private const RATE_LIMITERS = [ + MigrationApiRateLimiter::LOG_ACCESS => [ + 'enabled' => true, + 'policy' => 'sliding_window', + 'limit' => 30, + 'interval' => '60 seconds', + ], + MigrationApiRateLimiter::DOWNLOAD => [ + 'enabled' => true, + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '60 seconds', + ], + ]; + + public function process(ContainerBuilder $container): void + { + $rateLimiter = $container->getDefinition(RateLimiter::class); + + foreach (self::RATE_LIMITERS as $name => $config) { + $limiterFactory = new Definition(RateLimiterFactory::class); + $limiterFactory->addArgument($config + ['id' => $name]); + + $cacheStorage = new Definition(CacheStorage::class); + $cacheStorage->addArgument(new Reference('cache.rate_limiter')); + + $limiterFactory->addArgument($cacheStorage); + $limiterFactory->addArgument(new Reference(SystemConfigService::class)); + $limiterFactory->addArgument(new Reference(ClockInterface::class)); + $limiterFactory->addArgument(new Reference('lock.factory')); + + $rateLimiter->addMethodCall('registerLimiterFactory', [$name, $limiterFactory]); + } + } +} diff --git a/src/DependencyInjection/dataProvider.php b/src/DependencyInjection/dataProvider.php index 5f2a81953..d26ac6920 100644 --- a/src/DependencyInjection/dataProvider.php +++ b/src/DependencyInjection/dataProvider.php @@ -69,6 +69,7 @@ use SwagMigrationAssistant\DataProvider\Provider\Data\UserProvider; use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistry; use SwagMigrationAssistant\DataProvider\Service\EnvironmentService; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; return static function (ContainerConfigurator $container): void { $services = $container->services(); @@ -85,6 +86,7 @@ service('media.repository'), service(MediaService::class), service('shopware.filesystem.private'), + service(MigrationApiRateLimiter::class), ]) ->call('setContainer', [service('service_container')]); diff --git a/src/DependencyInjection/migration.php b/src/DependencyInjection/migration.php index be8aaba14..8e2ca1214 100644 --- a/src/DependencyInjection/migration.php +++ b/src/DependencyInjection/migration.php @@ -14,6 +14,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriter; +use Shopware\Core\Framework\RateLimiter\RateLimiter; use Shopware\Core\Framework\Store\Services\TrackingEventClient; use Shopware\Storefront\Theme\ThemeService; use SwagMigrationAssistant\Controller\ErrorResolutionController; @@ -93,6 +94,7 @@ use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use SwagMigrationAssistant\Migration\Writer\WriterRegistry; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; use Symfony\Component\EventDispatcher\EventDispatcherInterface; return static function (ContainerConfigurator $container): void { @@ -359,6 +361,9 @@ service(MigrationFingerprintService::class), ]); + $services->set(MigrationApiRateLimiter::class) + ->args([service(RateLimiter::class)]); + $services->set(StatusController::class) ->public() ->args([ @@ -380,6 +385,7 @@ service(HistoryService::class), service(LogGroupingService::class), '%shopware.api.max_limit%', + service(MigrationApiRateLimiter::class), ]) ->call('setContainer', [service('service_container')]); diff --git a/src/RateLimiter/MigrationApiRateLimiter.php b/src/RateLimiter/MigrationApiRateLimiter.php new file mode 100644 index 000000000..6951727f8 --- /dev/null +++ b/src/RateLimiter/MigrationApiRateLimiter.php @@ -0,0 +1,43 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\RateLimiter; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\RateLimiter\RateLimiter; +use Shopware\Core\PlatformRequest; +use Symfony\Component\HttpFoundation\Request; + +#[Package('fundamentals@after-sales')] +class MigrationApiRateLimiter +{ + final public const LOG_ACCESS = 'swag_migration_log_access'; + + final public const DOWNLOAD = 'swag_migration_download'; + + public function __construct( + private readonly RateLimiter $rateLimiter, + ) { + } + + public function ensureLogAccessAccepted(Request $request): void + { + $this->rateLimiter->ensureAccepted(self::LOG_ACCESS, $this->resolveKey($request)); + } + + public function ensureDownloadAccepted(Request $request): void + { + $this->rateLimiter->ensureAccepted(self::DOWNLOAD, $this->resolveKey($request)); + } + + private function resolveKey(Request $request): string + { + return $request->attributes->getString(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID) + ?: $request->getClientIp() + ?: 'unknown'; + } +} diff --git a/src/SwagMigrationAssistant.php b/src/SwagMigrationAssistant.php index 0e70d404f..ec291062b 100644 --- a/src/SwagMigrationAssistant.php +++ b/src/SwagMigrationAssistant.php @@ -15,6 +15,7 @@ use Shopware\Core\Framework\Plugin\Context\InstallContext; use Shopware\Core\Framework\Plugin\Context\UninstallContext; use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\DependencyInjection\CompilerPass\MigrationRateLimiterCompilerPass; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionDefinition; use SwagMigrationAssistant\Migration\Data\SwagMigrationDataDefinition; use SwagMigrationAssistant\Migration\ErrorResolution\Entity\SwagMigrationFixDefinition; @@ -65,6 +66,8 @@ public function build(ContainerBuilder $container): void $phpLoader->load('profile.php'); $phpLoader->load('subscriber.php'); $phpLoader->load('writer.php'); + + $container->addCompilerPass(new MigrationRateLimiterCompilerPass()); } public function rebuildContainer(): bool diff --git a/tests/Migration/Controller/HistoryControllerTest.php b/tests/Migration/Controller/HistoryControllerTest.php index 2dc01df09..e938c4f75 100644 --- a/tests/Migration/Controller/HistoryControllerTest.php +++ b/tests/Migration/Controller/HistoryControllerTest.php @@ -13,9 +13,12 @@ use Shopware\Core\Framework\DataAbstractionLayer\Entity; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\RateLimiter\Exception\RateLimitExceededException; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\Framework\Test\RateLimiter\DisableRateLimiterCompilerPass; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Controller\HistoryController; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\History\HistoryService; @@ -56,8 +59,20 @@ class HistoryControllerTest extends TestCase private string $connectionId; + public static function setUpBeforeClass(): void + { + DisableRateLimiterCompilerPass::disableNoLimit(); + } + + public static function tearDownAfterClass(): void + { + DisableRateLimiterCompilerPass::enableNoLimit(); + } + protected function setUp(): void { + static::getContainer()->get('cache.rate_limiter')->clear(); + $this->context = Context::createDefaultContext(); $this->runUuid = Uuid::randomHex(); $this->historyService = static::getContainer()->get(HistoryService::class); @@ -116,6 +131,11 @@ protected function setUp(): void ], $this->context); } + protected function tearDown(): void + { + static::getContainer()->get('cache.rate_limiter')->clear(); + } + public function testGetGroupedLogsOfRunWithoutUuid(): void { $request = new Request(); @@ -157,6 +177,32 @@ public function testDownloadLogsOfRun(): void static::assertSame('text/plain', $response->headers->get('Content-type')); } + public function testGetGroupedLogsOfRunIsRateLimited(): void + { + $request = new Request(['runUuid' => $this->runUuid]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID, 'rate-limited-token'); + + for ($i = 0; $i < 30; ++$i) { + $this->controller->getGroupedLogsOfRun($request, $this->context); + } + + $this->expectException(RateLimitExceededException::class); + $this->controller->getGroupedLogsOfRun($request, $this->context); + } + + public function testDownloadLogsOfRunIsRateLimited(): void + { + $request = new Request([], ['runUuid' => $this->runUuid]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID, 'rate-limited-download-token'); + + for ($i = 0; $i < 10; ++$i) { + $this->controller->downloadLogsOfRun($request, $this->context); + } + + $this->expectException(RateLimitExceededException::class); + $this->controller->downloadLogsOfRun($request, $this->context); + } + public function testGetLogChunk(): void { $result = $this->invokeMethod($this->historyService, 'getLogChunk', [$this->runUuid, 0, $this->context]); diff --git a/tests/RateLimiter/MigrationApiRateLimiterTest.php b/tests/RateLimiter/MigrationApiRateLimiterTest.php new file mode 100644 index 000000000..77633755c --- /dev/null +++ b/tests/RateLimiter/MigrationApiRateLimiterTest.php @@ -0,0 +1,59 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\RateLimiter; + +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\RateLimiter\RateLimiter; +use Shopware\Core\PlatformRequest; +use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter; +use Symfony\Component\HttpFoundation\Request; + +#[Package('fundamentals@after-sales')] +class MigrationApiRateLimiterTest extends TestCase +{ + public function testLogAccessUsesOauthTokenIdWhenAvailable(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID, 'token-id'); + + $rateLimiter = $this->createMock(RateLimiter::class); + $rateLimiter->expects($this->once()) + ->method('ensureAccepted') + ->with(MigrationApiRateLimiter::LOG_ACCESS, 'token-id'); + + $migrationApiRateLimiter = new MigrationApiRateLimiter($rateLimiter); + $migrationApiRateLimiter->ensureLogAccessAccepted($request); + } + + public function testDownloadFallsBackToClientIp(): void + { + $request = new Request(server: ['REMOTE_ADDR' => '127.0.0.1']); + + $rateLimiter = $this->createMock(RateLimiter::class); + $rateLimiter->expects($this->once()) + ->method('ensureAccepted') + ->with(MigrationApiRateLimiter::DOWNLOAD, '127.0.0.1'); + + $migrationApiRateLimiter = new MigrationApiRateLimiter($rateLimiter); + $migrationApiRateLimiter->ensureDownloadAccepted($request); + } + + public function testDownloadUsesUnknownWhenNoRequestIdentityIsAvailable(): void + { + $request = new Request(); + + $rateLimiter = $this->createMock(RateLimiter::class); + $rateLimiter->expects($this->once()) + ->method('ensureAccepted') + ->with(MigrationApiRateLimiter::DOWNLOAD, 'unknown'); + + $migrationApiRateLimiter = new MigrationApiRateLimiter($rateLimiter); + $migrationApiRateLimiter->ensureDownloadAccepted($request); + } +} diff --git a/tests/ServiceCorrectArgumentsTest.php b/tests/ServiceCorrectArgumentsTest.php index 6f76c9d9d..364b82541 100644 --- a/tests/ServiceCorrectArgumentsTest.php +++ b/tests/ServiceCorrectArgumentsTest.php @@ -43,6 +43,7 @@ public static function serviceProvider(): array $finder = new Finder(); $finder->in(SwagMigrationAssistant::DEPENDENCY_LOCATION) ->files() + ->depth('== 0') ->name('*.php'); foreach ($finder as $file) {