diff --git a/.symfony/recipe/nowo-tech/qr-code-bundle/0.1/config/packages/nowo_qr_code.yaml b/.symfony/recipe/nowo-tech/qr-code-bundle/0.1/config/packages/nowo_qr_code.yaml index 347b6ad..1226b80 100644 --- a/.symfony/recipe/nowo-tech/qr-code-bundle/0.1/config/packages/nowo_qr_code.yaml +++ b/.symfony/recipe/nowo-tech/qr-code-bundle/0.1/config/packages/nowo_qr_code.yaml @@ -18,6 +18,7 @@ nowo_qr_code: when@prod: nowo_qr_code: + url_allowlist_required: true profiles: default: # Set host allowlist in production when encoding URLs (not plain text). diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 386c130..621ded2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -51,6 +51,10 @@ public function getConfigTreeBuilder(): TreeBuilder }) ->end() ->children() + ->booleanNode('url_allowlist_required') + ->defaultFalse() + ->info('When true, container compilation fails if the default profile url_allowlist is empty (production hardening).') + ->end() ->booleanNode('use_database_config') ->info('When true, Doctrine rows with the same profile name fully override YAML profiles; enables admin CRUD and requires doctrine/orm') ->defaultFalse() diff --git a/src/DependencyInjection/NowoQrCodeExtension.php b/src/DependencyInjection/NowoQrCodeExtension.php index 793b735..93c0098 100644 --- a/src/DependencyInjection/NowoQrCodeExtension.php +++ b/src/DependencyInjection/NowoQrCodeExtension.php @@ -182,6 +182,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('nowo_qr_code.margin', $default['margin']); $container->setParameter('nowo_qr_code.error_correction', $default['error_correction']); $container->setParameter('nowo_qr_code.url_allowlist', $default['url_allowlist']); + $container->setParameter('nowo_qr_code.url_allowlist_required', $config['url_allowlist_required']); $container->setParameter('nowo_qr_code.use_database_config', $config['use_database_config']); $container->setParameter('nowo_qr_code.doctrine.table_prefix', $config['doctrine']['table_prefix']); $container->setParameter('nowo_qr_code.security.access_roles', $config['security']['access_roles']); diff --git a/src/DependencyInjection/UrlAllowlistValidationPass.php b/src/DependencyInjection/UrlAllowlistValidationPass.php new file mode 100644 index 0000000..0e48063 --- /dev/null +++ b/src/DependencyInjection/UrlAllowlistValidationPass.php @@ -0,0 +1,39 @@ +hasParameter(self::PARAM_ALLOWLIST)) { + return; + } + + $allowlistRequired = $container->hasParameter(self::PARAM_ALLOWLIST_REQUIRED) + && (bool) $container->getParameter(self::PARAM_ALLOWLIST_REQUIRED); + + if (!$allowlistRequired) { + return; + } + + /** @var list $allowlist */ + $allowlist = $container->getParameter(self::PARAM_ALLOWLIST); + + if ($allowlist === []) { + throw new InvalidConfigurationException('nowo_qr_code.url_allowlist_required is true but the default profile url_allowlist is empty. Add host patterns (or set url_allowlist_required: false for local demos only).'); + } + } +} diff --git a/src/NowoQrCodeBundle.php b/src/NowoQrCodeBundle.php index 7f3e7fb..1db1a99 100644 --- a/src/NowoQrCodeBundle.php +++ b/src/NowoQrCodeBundle.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; use Nowo\QrCodeBundle\DependencyInjection\Compiler\TwigPathsPass; use Nowo\QrCodeBundle\DependencyInjection\NowoQrCodeExtension; +use Nowo\QrCodeBundle\DependencyInjection\UrlAllowlistValidationPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -23,6 +24,7 @@ public function build(ContainerBuilder $container): void { parent::build($container); $container->addCompilerPass(new TwigPathsPass()); + $container->addCompilerPass(new UrlAllowlistValidationPass()); $entityDir = __DIR__ . '/Entity'; if (class_exists(DoctrineOrmMappingsPass::class) && is_dir($entityDir)) { diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 3adec90..bd94d06 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -37,6 +37,7 @@ public function testDefaultConfiguration(): void $this->assertSame(10, $config['profiles']['default']['margin']); $this->assertSame('high', $config['profiles']['default']['error_correction']); $this->assertSame([], $config['profiles']['default']['url_allowlist']); + $this->assertFalse($config['url_allowlist_required']); $this->assertFalse($config['use_database_config']); $this->assertSame('', $config['doctrine']['table_prefix']); $this->assertSame(['ROLE_ADMIN'], $config['security']['access_roles']); @@ -133,6 +134,15 @@ public function testNamedProfiles(): void $this->assertSame(['nowo.tech'], $config['profiles']['compact']['url_allowlist']); } + public function testUrlAllowlistRequiredFlag(): void + { + $config = $this->processor->processConfiguration($this->configuration, [[ + 'url_allowlist_required' => true, + ]]); + + $this->assertTrue($config['url_allowlist_required']); + } + public function testUnknownDefaultProfileIsRejected(): void { $this->expectException(InvalidConfigurationException::class); diff --git a/tests/Unit/DependencyInjection/NowoQrCodeExtensionTest.php b/tests/Unit/DependencyInjection/NowoQrCodeExtensionTest.php index b406ad3..8e70224 100644 --- a/tests/Unit/DependencyInjection/NowoQrCodeExtensionTest.php +++ b/tests/Unit/DependencyInjection/NowoQrCodeExtensionTest.php @@ -41,6 +41,7 @@ public function testLoadRegistersServicesAndParameters(): void $this->assertSame(10, $container->getParameter('nowo_qr_code.margin')); $this->assertSame('high', $container->getParameter('nowo_qr_code.error_correction')); $this->assertSame([], $container->getParameter('nowo_qr_code.url_allowlist')); + $this->assertFalse($container->getParameter('nowo_qr_code.url_allowlist_required')); $this->assertSame('@NowoQrCodeBundle/admin/layout.html.twig', $container->getParameter('nowo_qr_code.web_ui.layout_template')); $this->assertSame(CssFramework::Custom->value, $container->getParameter('nowo_qr_code.web_ui.css_framework')); } @@ -335,4 +336,12 @@ public function testLoadNamedProfiles(): void $this->assertSame(200, $container->getParameter('nowo_qr_code.size')); $this->assertSame(['pay.google.com'], $container->getParameter('nowo_qr_code.url_allowlist')); } + + public function testLoadSetsUrlAllowlistRequiredParameter(): void + { + $container = new ContainerBuilder(); + $this->extension->load([['url_allowlist_required' => true]], $container); + + $this->assertTrue($container->getParameter('nowo_qr_code.url_allowlist_required')); + } } diff --git a/tests/Unit/DependencyInjection/UrlAllowlistValidationPassTest.php b/tests/Unit/DependencyInjection/UrlAllowlistValidationPassTest.php new file mode 100644 index 0000000..7770d8d --- /dev/null +++ b/tests/Unit/DependencyInjection/UrlAllowlistValidationPassTest.php @@ -0,0 +1,57 @@ +setParameter('nowo_qr_code.url_allowlist', []); + $container->setParameter('nowo_qr_code.url_allowlist_required', true); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('url_allowlist_required is true'); + + (new UrlAllowlistValidationPass())->process($container); + } + + public function testPassesWhenRequiredAndAllowlistNonEmpty(): void + { + $container = new ContainerBuilder(); + $container->setParameter('nowo_qr_code.url_allowlist', ['example.com']); + $container->setParameter('nowo_qr_code.url_allowlist_required', true); + + (new UrlAllowlistValidationPass())->process($container); + + $this->expectNotToPerformAssertions(); + } + + public function testPassesWhenNotRequiredAndAllowlistEmpty(): void + { + $container = new ContainerBuilder(); + $container->setParameter('nowo_qr_code.url_allowlist', []); + $container->setParameter('nowo_qr_code.url_allowlist_required', false); + + (new UrlAllowlistValidationPass())->process($container); + + $this->expectNotToPerformAssertions(); + } + + public function testSkipsWhenAllowlistParameterMissing(): void + { + (new UrlAllowlistValidationPass())->process(new ContainerBuilder()); + + $this->expectNotToPerformAssertions(); + } +}