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
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/NowoQrCodeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
39 changes: 39 additions & 0 deletions src/DependencyInjection/UrlAllowlistValidationPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Nowo\QrCodeBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* When url_allowlist_required is true, empty default-profile allowlist fails compilation (REQ-SEC-004).
*/
final class UrlAllowlistValidationPass implements CompilerPassInterface
{
private const PARAM_ALLOWLIST = 'nowo_qr_code.url_allowlist';
private const PARAM_ALLOWLIST_REQUIRED = 'nowo_qr_code.url_allowlist_required';

public function process(ContainerBuilder $container): void
{
if (!$container->hasParameter(self::PARAM_ALLOWLIST)) {
return;
}

$allowlistRequired = $container->hasParameter(self::PARAM_ALLOWLIST_REQUIRED)
&& (bool) $container->getParameter(self::PARAM_ALLOWLIST_REQUIRED);

if (!$allowlistRequired) {
return;
}

/** @var list<string> $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).');
}
}
}
2 changes: 2 additions & 0 deletions src/NowoQrCodeBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)) {
Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/DependencyInjection/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions tests/Unit/DependencyInjection/NowoQrCodeExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down Expand Up @@ -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'));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Nowo\QrCodeBundle\Tests\Unit\DependencyInjection;

use Nowo\QrCodeBundle\DependencyInjection\UrlAllowlistValidationPass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* @covers \Nowo\QrCodeBundle\DependencyInjection\UrlAllowlistValidationPass
*/
final class UrlAllowlistValidationPassTest extends TestCase
{
public function testFailsWhenRequiredAndAllowlistEmpty(): void
{
$container = new ContainerBuilder();
$container->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();
}
}