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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
composer require symfony/yaml:^${{ matrix.symfony }} --dev --no-interaction --no-update
composer update --with-all-dependencies --no-interaction --prefer-dist --no-progress

- name: Composer audit (Critical/High)
run: composer audit --locked

- name: Run tests
run: |
echo "🧪 Running tests on PHP ${{ matrix.php }} with Symfony ${{ matrix.symfony }}..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,14 @@ nowo_page_layout_kit:
web_ui:
layout_template: '@NowoPageLayoutKitBundle/admin/layout.html.twig'
css_framework: tailwind
html:
sanitize:
strategy: none
doctrine:
table_prefix: ''

when@prod:
nowo_page_layout_kit:
html:
sanitize:
strategy: allowlist
15 changes: 15 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,23 @@ nowo_page_layout_kit:
doctrine:
table_prefix: ''
connection: default
html:
sanitize:
strategy: none # none | strip | allowlist | service
service: null
```

Production (Flex recipe): `when@prod` sets `html.sanitize.strategy: allowlist`.

## html.sanitize

| Key | Default | Description |
| --- | --- | --- |
| `strategy` | `none` | `none` (trusted editors), `strip`, `allowlist`, or `service` |
| `service` | `null` | Host service implementing `PageLayoutHtmlSanitizerInterface` when `strategy: service` |

Sanitization runs on Doctrine persist/update for text/compare/cta block translations and again when serving public layouts via `PageBlockProvider`.

## Top-level options

| Key | Type | Default | Description |
Expand Down
20 changes: 16 additions & 4 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,23 @@ Recommendations:

## Rich text rendering

Several default public block templates render editor-authored HTML with `|raw`, especially for long-form text and compare content. That is intentional for CMS-managed rich text, but it means:
Several default public block templates render editor-authored HTML with `|raw`, especially for long-form text and compare content. That is intentional for CMS-managed rich text.

- Only trusted editors should be able to update those fields.
- Hosts should sanitize content before storage or before rendering if untrusted HTML is possible.
- Template overrides should keep escaping behavior explicit and reviewed.
**HTML sanitization (2026-08-19):** configure `nowo_page_layout_kit.html.sanitize.strategy`:

| Strategy | Behaviour |
| -------- | --------- |
| `none` (default) | Trusted editors only; HTML stored/rendered as-is |
| `allowlist` | DOM allowlist on persist + public render (recipe `when@prod`) |
| `strip` | Remove all tags |
| `service` | Host `PageLayoutHtmlSanitizerInterface` |

Flex recipe sets `when@prod: strategy: allowlist`. Demo/dev may keep `none`.

Additional guidance:

- Only trusted editors should be able to update rich-text fields.
- Template overrides should keep escaping behaviour explicit and reviewed.

## Operational guidance

Expand Down
20 changes: 20 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Nowo\PageLayoutKitBundle\DependencyInjection;

use Nowo\PageLayoutKitBundle\Enum\HtmlSanitizeStrategy;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
Expand Down Expand Up @@ -74,6 +75,25 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('connection')->defaultValue('default')->end()
->end()
->end()
->arrayNode('html')
->addDefaultsIfNotSet()
->children()
->arrayNode('sanitize')
->addDefaultsIfNotSet()
->info('Sanitize block HTML on persist and public render. Default none keeps trusted-editor |raw.')
->children()
->enumNode('strategy')
->values(HtmlSanitizeStrategy::values())
->defaultValue(HtmlSanitizeStrategy::None->value)
->end()
->scalarNode('service')
->defaultNull()
->info('Service id implementing PageLayoutHtmlSanitizerInterface when strategy=service.')
->end()
->end()
->end()
->end()
->end()
->end();

return $treeBuilder;
Expand Down
40 changes: 40 additions & 0 deletions src/DependencyInjection/NowoPageLayoutKitExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
use Doctrine\ORM\Events;
use LogicException;
use Nowo\PageLayoutKitBundle\DependencyInjection\Configuration as BundleConfiguration;
use Nowo\PageLayoutKitBundle\Enum\HtmlSanitizeStrategy;
use Nowo\PageLayoutKitBundle\Locale\PageLocales;
use Nowo\PageLayoutKitBundle\Security\AllowAllPageLayoutKitAccessChecker;
use Nowo\PageLayoutKitBundle\Security\ConfigurablePageLayoutKitAccessChecker;
use Nowo\PageLayoutKitBundle\Security\PageLayoutKitAccessCheckerInterface;
use Nowo\PageLayoutKitBundle\Security\PageLayoutProtection;
use Nowo\PageLayoutKitBundle\Security\PageLayoutProtectionConfig;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
Expand Down Expand Up @@ -92,6 +95,7 @@ public function load(array $configs, ContainerBuilder $container): void
}

$this->registerAccessChecker($container, $config['security']);
$this->registerPageLayoutProtection($container, $config);

$tablePrefix = (string) $config['doctrine']['table_prefix'];
if ($tablePrefix !== '') {
Expand Down Expand Up @@ -134,6 +138,42 @@ private function registerAccessChecker(ContainerBuilder $container, array $secur
$container->setAlias(PageLayoutKitAccessCheckerInterface::class, $id);
}

/**
* @param array<string, mixed> $config
*/
private function registerPageLayoutProtection(ContainerBuilder $container, array $config): void
{
/** @var array<string, mixed> $html */
$html = $config['html']['sanitize'];

$container->register(PageLayoutProtectionConfig::class)
->setAutowired(false)
->setAutoconfigured(false)
->setArguments([
HtmlSanitizeStrategy::from((string) $html['strategy']),
$this->optionalServiceId($html['service'] ?? null),
]);

$customSanitizer = $this->optionalServiceId($html['service'] ?? null);

$container->register(PageLayoutProtection::class)
->setAutowired(false)
->setAutoconfigured(false)
->setArguments([
new Reference(PageLayoutProtectionConfig::class),
$customSanitizer !== null ? new Reference($customSanitizer) : null,
]);
}

private function optionalServiceId(mixed $serviceId): ?string
{
if (!is_string($serviceId) || $serviceId === '') {
return null;
}

return $serviceId;
}

private function isSecurityBundleAvailable(ContainerBuilder $container): bool
{
if ($container->hasExtension('security')) {
Expand Down
24 changes: 24 additions & 0 deletions src/Enum/HtmlSanitizeStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Nowo\PageLayoutKitBundle\Enum;

/**
* CMS block HTML sanitizer strategies (persist + public render).
*/
enum HtmlSanitizeStrategy: string
{
case None = 'none';
case Strip = 'strip';
case Allowlist = 'allowlist';
case Service = 'service';

/**
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}
72 changes: 72 additions & 0 deletions src/EventSubscriber/PageBlockHtmlSanitizeSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Nowo\PageLayoutKitBundle\EventSubscriber;

use Doctrine\Persistence\Event\LifecycleEventArgs;
use Doctrine\Persistence\ObjectManager;
use Nowo\PageLayoutKitBundle\Entity\PageCompareBlockTranslation;
use Nowo\PageLayoutKitBundle\Entity\PageCtaBlockTranslation;
use Nowo\PageLayoutKitBundle\Entity\PageTextBlockTranslation;
use Nowo\PageLayoutKitBundle\Security\PageLayoutProtection;

/**
* Sanitizes block translation HTML on persist/update.
*/
final readonly class PageBlockHtmlSanitizeSubscriber
{
public function __construct(
private PageLayoutProtection $protection,
) {
}

/**
* @param LifecycleEventArgs<ObjectManager> $args
*/
public function prePersist(LifecycleEventArgs $args): void
{
$this->sanitize($args->getObject());
}

/**
* @param LifecycleEventArgs<ObjectManager> $args
*/
public function preUpdate(LifecycleEventArgs $args): void
{
$this->sanitize($args->getObject());
}

private function sanitize(object $entity): void
{
$sanitizer = $this->protection->htmlSanitizer();

if ($entity instanceof PageTextBlockTranslation) {
$body = $entity->getBody();
if ($body !== '') {
$entity->setBody($sanitizer->sanitize($body));
}

return;
}

if ($entity instanceof PageCompareBlockTranslation) {
if ($entity->getBeforeText() !== '') {
$entity->setBeforeText($sanitizer->sanitize($entity->getBeforeText()));
}

if ($entity->getAfterText() !== '') {
$entity->setAfterText($sanitizer->sanitize($entity->getAfterText()));
}

return;
}

if ($entity instanceof PageCtaBlockTranslation) {
$body = $entity->getBody();
if ($body !== '') {
$entity->setBody($sanitizer->sanitize($body));
}
}
}
}
9 changes: 9 additions & 0 deletions src/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ services:
- '../../Controller/'
- '../../NowoPageLayoutKitBundle.php'
- '../../Resources/'
- '../../Security/PageLayoutProtection.php'
- '../../Security/PageLayoutProtectionConfig.php'
- '../../Security/Html/'

Nowo\PageLayoutKitBundle\Controller\:
resource: '../../Controller/'
Expand All @@ -37,6 +40,12 @@ services:
Nowo\PageLayoutKitBundle\Service\PageBlockProvider:
arguments:
$legacyContentProvider: '@?Nowo\PageLayoutKitBundle\Legacy\LegacyPageContentProviderInterface'
$protection: '@Nowo\PageLayoutKitBundle\Security\PageLayoutProtection'

Nowo\PageLayoutKitBundle\EventSubscriber\PageBlockHtmlSanitizeSubscriber:
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }

Nowo\PageLayoutKitBundle\Service\PageBlockMigrator:
arguments:
Expand Down
Loading
Loading