diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1fcdb33..145770b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -19,7 +19,7 @@ This Symfony bundle **tracks route performance metrics** (timing, database query | SQL injection | Use Doctrine parameterized queries; validate sort/filter parameters. | | SSRF via webhook URLs | Only allow webhook URLs from trusted configuration (env), not from end-user POST bodies. | | Information leakage in exports | Restrict export actions to trusted roles; avoid exporting secrets from request attributes. | -| DoS via large exports or queries | Use pagination, limits, and infrastructure timeouts. | +| DoS via large exports or queries | Access-record exports capped by `export.max_rows` (default **5000**, max **50000** for BC); use pagination and infrastructure timeouts. | ## Admin UI guard (REQ-UI-002) @@ -88,6 +88,6 @@ Before tagging a release, confirm: | **Logging** | No secrets in application logs from bundle code paths. | | **Cryptography** | HTTPS/TLS for outbound calls is the deployer’s responsibility; document. | | **Permissions / exposure** | Dashboard and export routes require `access_roles` or custom checker; `allow_unauthenticated` is `false` by default. | -| **Limits / DoS** | Export size and query limits documented where applicable. | +| **Limits / DoS** | `export.max_rows` defaults to 5000 (configurable up to 50000). | Record confirmation in the release PR or tag notes. diff --git a/src/Controller/PerformanceController.php b/src/Controller/PerformanceController.php index 53eece4..7564f4b 100644 --- a/src/Controller/PerformanceController.php +++ b/src/Controller/PerformanceController.php @@ -154,6 +154,8 @@ public function __construct( #[Autowire('%nowo_performance.access_records_retention_days%')] private readonly ?int $accessRecordsRetentionDays = null, private readonly ?PerformanceAccessCheckerInterface $accessChecker = null, + #[Autowire('%nowo_performance.export.max_rows%')] + private readonly int $exportMaxRows = 5000, ) { } @@ -1120,6 +1122,7 @@ public function exportRecordsCsv(Request $request): StreamedResponse $maxMemoryUsage, $referer, $user, + $this->exportMaxRows, ); } catch (Throwable) { $result = ['records' => [], 'total' => 0]; @@ -1244,6 +1247,7 @@ public function exportRecordsJson(Request $request): Response $maxMemoryUsage, $referer, $user, + $this->exportMaxRows, ); } catch (Throwable) { $result = ['records' => [], 'total' => 0]; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index edb4324..a204e6b 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -320,6 +320,18 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('export') + ->info('Export limits for access-records CSV/JSON downloads') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('max_rows') + ->info('Maximum access-record rows returned by export endpoints. Default 5000; set up to 50000 to restore previous behavior.') + ->defaultValue(5000) + ->min(1) + ->max(50000) + ->end() + ->end() + ->end() ->arrayNode('notifications') ->info('Performance alert notifications configuration') ->addDefaultsIfNotSet() diff --git a/src/DependencyInjection/PerformanceExtension.php b/src/DependencyInjection/PerformanceExtension.php index ce5db1f..1c0a7e6 100644 --- a/src/DependencyInjection/PerformanceExtension.php +++ b/src/DependencyInjection/PerformanceExtension.php @@ -140,6 +140,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter($securityPath . '.access_checker', $securityConfig['access_checker'] ?? null); $container->setParameter($securityPath . '.allow_unauthenticated', $securityConfig['allow_unauthenticated'] ?? false); + $exportConfig = $config['export'] ?? []; + $container->setParameter(Configuration::ALIAS . '.export.max_rows', $exportConfig['max_rows'] ?? 5000); + $this->registerAccessChecker($container, $securityConfig); // Notifications configuration diff --git a/src/Repository/RouteDataRecordRepository.php b/src/Repository/RouteDataRecordRepository.php index 8579d5e..98ccee1 100644 --- a/src/Repository/RouteDataRecordRepository.php +++ b/src/Repository/RouteDataRecordRepository.php @@ -901,7 +901,7 @@ public function getPaginatedRecords( * @param int|null $maxMemoryUsage Optional max memory (bytes) * @param string|null $referer Optional referer filter (partial match) * @param string|null $user Optional user filter (partial match) - * @param int $limit Maximum records to return (default 50_000) + * @param int $limit Maximum records to return (default 5_000; configurable via nowo_performance.export.max_rows) * * @return array{records: RouteDataRecord[], total: int} */ @@ -918,7 +918,7 @@ public function getRecordsForExport( ?int $maxMemoryUsage = null, ?string $referer = null, ?string $user = null, - int $limit = 50_000, + int $limit = 5_000, ): array { $qb = $this->createQueryBuilder('r') ->join('r.routeData', 'rd') diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index a257618..1071aa9 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -41,6 +41,7 @@ public function testDefaultConfiguration(): void $this->assertSame(['ROLE_ADMIN'], $config['security']['access_roles']); $this->assertNull($config['security']['access_checker']); $this->assertFalse($config['security']['allow_unauthenticated']); + $this->assertSame(5000, $config['export']['max_rows']); } public function testCustomConfiguration(): void diff --git a/tests/Unit/DependencyInjection/PerformanceExtensionTest.php b/tests/Unit/DependencyInjection/PerformanceExtensionTest.php index a76eb56..21486d4 100644 --- a/tests/Unit/DependencyInjection/PerformanceExtensionTest.php +++ b/tests/Unit/DependencyInjection/PerformanceExtensionTest.php @@ -65,6 +65,7 @@ public function testLoadDefaultConfiguration(): void $this->assertSame('bootstrap', $this->container->getParameter('nowo_performance.dashboard.template')); $this->assertSame(['ROLE_ADMIN'], $this->container->getParameter('nowo_performance.security.access_roles')); $this->assertFalse($this->container->getParameter('nowo_performance.security.allow_unauthenticated')); + $this->assertSame(5000, $this->container->getParameter('nowo_performance.export.max_rows')); $this->assertTrue($this->container->hasAlias(PerformanceAccessCheckerInterface::class)); $this->assertTrue($this->container->hasDefinition('nowo_performance.access_checker.default')); $this->assertSame(