Skip to content

Commit 678a28f

Browse files
committed
[FEAT] add support for raw html sanitizers
This feature introduces a new compiler pass that will allow us to sanitize the html in a raw directive. We can disable the full raw usage by setting `raw_node.escape` to `true` this will block all html and displays the raw node as normal paragraph. By default the raw directive is displayed as is and sanitized to safe html, when raw is defined as html content ``` .. raw:: html <p>html here</p> ``` if the raw language is different than html we no nothing for now. This pr also allows the users to define there own html sanitizer from the configuration. Example can be found in the included test.
1 parent 5d2bbf1 commit 678a28f

File tree

20 files changed

+592
-110
lines changed

20 files changed

+592
-110
lines changed

composer.lock

Lines changed: 306 additions & 94 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/guides-cli/resources/schema/guides.xsd

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<xsd:element name="ignored_domain" type="xsd:string" minOccurs="0" maxOccurs="unbounded"/>
1818
<xsd:element name="inventory" type="inventory" minOccurs="0" maxOccurs="unbounded"/>
1919
<xsd:element name="template" type="template" minOccurs="0" maxOccurs="unbounded"/>
20+
<xsd:element name="raw-node" type="raw-node" minOccurs="0" maxOccurs="1" />
2021
</xsd:choice>
2122

2223
<xsd:attribute name="input" type="xsd:string"/>
@@ -32,7 +33,6 @@
3233
<xsd:attribute name="default-code-language" type="xsd:string"/>
3334
<xsd:attribute name="links-are-relative" type="xsd:string"/>
3435
<xsd:attribute name="max-menu-depth" type="xsd:int"/>
35-
3636
</xsd:complexType>
3737

3838
<xsd:complexType name="extension">
@@ -71,4 +71,24 @@
7171
<xsd:attribute name="node" type="xsd:string" use="required"/>
7272
<xsd:attribute name="format" type="xsd:string" default="html"/>
7373
</xsd:complexType>
74+
75+
<xsd:complexType name="raw-node">
76+
<xsd:choice minOccurs="0" maxOccurs="unbounded">
77+
<xsd:element name="sanitizer" type="sanitizer" minOccurs="0" maxOccurs="unbounded" />
78+
</xsd:choice>
79+
<xsd:attribute name="escape" type="xsd:boolean" />
80+
<xsd:attribute name="sanitizer-name" type="xsd:string" default="default" />
81+
</xsd:complexType>
82+
83+
<xsd:complexType name="sanitizer">
84+
<xsd:choice minOccurs="0" maxOccurs="unbounded">
85+
<xsd:element name="allow-element" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
86+
<xsd:element name="block-element" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
87+
<xsd:element name="drop-element" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
88+
<xsd:element name="allow-attribute" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
89+
<xsd:element name="drop-attribute" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
90+
</xsd:choice>
91+
92+
<xsd:attribute name="name" type="xsd:string" use="required" />
93+
</xsd:complexType>
7494
</xsd:schema>

packages/guides-markdown/src/Markdown/Parsers/HtmlParser.php

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,21 @@
1818
use League\CommonMark\Node\NodeWalker;
1919
use League\CommonMark\Node\NodeWalkerEvent;
2020
use phpDocumentor\Guides\MarkupLanguageParser;
21-
use phpDocumentor\Guides\Nodes\Inline\PlainTextInlineNode;
22-
use phpDocumentor\Guides\Nodes\InlineCompoundNode;
2321
use phpDocumentor\Guides\Nodes\ParagraphNode;
24-
use Psr\Log\LoggerInterface;
22+
use phpDocumentor\Guides\Nodes\RawNode;
2523

2624
use function assert;
2725

2826
/** @extends AbstractBlockParser<ParagraphNode> */
2927
final class HtmlParser extends AbstractBlockParser
3028
{
31-
public function __construct(
32-
private readonly LoggerInterface $logger,
33-
) {
34-
}
35-
36-
public function parse(MarkupLanguageParser $parser, NodeWalker $walker, CommonMarkNode $current): ParagraphNode
29+
public function parse(MarkupLanguageParser $parser, NodeWalker $walker, CommonMarkNode $current): RawNode
3730
{
3831
assert($current instanceof HtmlBlock);
3932

40-
$this->logger->warning('We do not support plain HTML for security reasons. Escaping all HTML ');
41-
4233
$walker->next();
4334

44-
return new ParagraphNode([new InlineCompoundNode([new PlainTextInlineNode($current->getLiteral())])]);
35+
return new RawNode($current->getLiteral());
4536
}
4637

4738
public function supports(NodeWalkerEvent $event): bool

packages/guides-restructured-text/src/RestructuredText/Directives/RawDirective.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ public function process(
4141
BlockContext $blockContext,
4242
Directive $directive,
4343
): Node|null {
44-
return new RawNode(implode("\n", $blockContext->getDocumentIterator()->toArray()));
44+
return new RawNode(
45+
implode("\n", $blockContext->getDocumentIterator()->toArray()),
46+
$directive->getData(),
47+
);
4548
}
4649
}

packages/guides/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@
3131
"phpdocumentor/flyfinder": "^1.1",
3232
"psr/event-dispatcher": "^1.0",
3333
"symfony/clock": "^6.4.8",
34+
"symfony/html-sanitizer": "^6.4.8",
35+
"symfony/http-client": "^6.4.9",
3436
"symfony/string": "^6.4.9",
3537
"symfony/translation-contracts": "^3.5.0",
36-
"symfony/http-client": "^6.4.9",
3738
"twig/twig": "~2.15 || ^3.0",
3839
"webmozart/assert": "^1.11"
3940
},

packages/guides/resources/config/guides.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use phpDocumentor\Guides\Compiler\NodeTransformers\CustomNodeTransformerFactory;
1111
use phpDocumentor\Guides\Compiler\NodeTransformers\MenuNodeTransformers\InternalMenuEntryNodeTransformer;
1212
use phpDocumentor\Guides\Compiler\NodeTransformers\NodeTransformerFactory;
13+
use phpDocumentor\Guides\Compiler\NodeTransformers\RawNodeEscapeTransformer;
1314
use phpDocumentor\Guides\Event\PostProjectNodeCreated;
1415
use phpDocumentor\Guides\EventListener\LoadSettingsFromComposer;
1516
use phpDocumentor\Guides\NodeRenderers\Html\BreadCrumbNodeRenderer;
@@ -62,6 +63,7 @@
6263
use phpDocumentor\Guides\Twig\TwigTemplateRenderer;
6364
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
6465
use Symfony\Component\DependencyInjection\Reference;
66+
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
6567
use Symfony\Component\HttpClient\HttpClient;
6668
use Symfony\Contracts\HttpClient\HttpClientInterface;
6769
use Twig\Loader\FilesystemLoader;
@@ -105,6 +107,9 @@
105107
->set(InternalMenuEntryNodeTransformer::class)
106108
->tag('phpdoc.guides.compiler.nodeTransformers')
107109

110+
->set(RawNodeEscapeTransformer::class)
111+
->arg('$escapeRawNodes', param('phpdoc.guides.raw_node.escape'))
112+
->arg('$htmlSanitizerConfig', service('phpdoc.guides.raw_node.sanitizer.default'))
108113

109114
->set(AbsoluteUrlGenerator::class)
110115
->set(RelativeUrlGenerator::class)
@@ -244,5 +249,8 @@
244249
->arg('$themeManager', service(ThemeManager::class))
245250

246251
->set(TemplateRenderer::class, TwigTemplateRenderer::class)
247-
->arg('$environmentBuilder', new Reference(EnvironmentBuilder::class));
252+
->arg('$environmentBuilder', new Reference(EnvironmentBuilder::class))
253+
254+
->set('phpdoc.guides.raw_node.sanitizer.default', HtmlSanitizerConfig::class)
255+
->call('allowSafeElements', [], true);
248256
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of phpDocumentor.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @link https://phpdoc.org
12+
*/
13+
14+
namespace phpDocumentor\Guides\Compiler\NodeTransformers;
15+
16+
use phpDocumentor\Guides\Compiler\CompilerContext;
17+
use phpDocumentor\Guides\Compiler\NodeTransformer;
18+
use phpDocumentor\Guides\Nodes\Inline\PlainTextInlineNode;
19+
use phpDocumentor\Guides\Nodes\InlineCompoundNode;
20+
use phpDocumentor\Guides\Nodes\Node;
21+
use phpDocumentor\Guides\Nodes\ParagraphNode;
22+
use phpDocumentor\Guides\Nodes\RawNode;
23+
use Psr\Log\LoggerInterface;
24+
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
25+
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
26+
27+
final class RawNodeEscapeTransformer implements NodeTransformer
28+
{
29+
private HtmlSanitizer $htmlSanitizer;
30+
31+
public function __construct(
32+
private readonly bool $escapeRawNodes,
33+
private readonly LoggerInterface $logger,
34+
HtmlSanitizerConfig $htmlSanitizerConfig,
35+
) {
36+
$this->htmlSanitizer = new HtmlSanitizer($htmlSanitizerConfig);
37+
}
38+
39+
public function enterNode(Node $node, CompilerContext $compilerContext): Node
40+
{
41+
return $node;
42+
}
43+
44+
public function leaveNode(Node $node, CompilerContext $compilerContext): Node|null
45+
{
46+
if ($this->escapeRawNodes) {
47+
$this->logger->warning('We do not support plain HTML for security reasons. Escaping all HTML ');
48+
49+
return new ParagraphNode([new InlineCompoundNode([new PlainTextInlineNode($node->getValue())])]);
50+
}
51+
52+
if ($node->getOption('format', 'html') === 'html') {
53+
return new RawNode($this->htmlSanitizer->sanitize($node->getValue()));
54+
}
55+
56+
return $node;
57+
}
58+
59+
public function supports(Node $node): bool
60+
{
61+
return $node instanceof RawNode;
62+
}
63+
64+
public function getPriority(): int
65+
{
66+
return 1000;
67+
}
68+
}

packages/guides/src/DependencyInjection/GuidesExtension.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace phpDocumentor\Guides\DependencyInjection;
1515

16+
use phpDocumentor\Guides\Compiler\NodeTransformers\RawNodeEscapeTransformer;
1617
use phpDocumentor\Guides\DependencyInjection\Compiler\NodeRendererPass;
1718
use phpDocumentor\Guides\DependencyInjection\Compiler\ParserRulesPass;
1819
use phpDocumentor\Guides\DependencyInjection\Compiler\RendererPass;
@@ -31,6 +32,8 @@
3132
use Symfony\Component\DependencyInjection\Extension\Extension;
3233
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
3334
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
35+
use Symfony\Component\DependencyInjection\Reference;
36+
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
3437

3538
use function array_keys;
3639
use function array_map;
@@ -162,6 +165,33 @@ static function ($value) {
162165
->end()
163166
->end()
164167
->end()
168+
->arrayNode('raw_node')
169+
->fixXmlConfig('sanitizer')
170+
->children()
171+
->booleanNode('escape')->defaultValue(false)->end()
172+
->scalarNode('sanitizer_name')->end()
173+
->arrayNode('sanitizers')
174+
->defaultValue([])
175+
->arrayPrototype()
176+
->fixXmlConfig('allow_element')
177+
->fixXmlConfig('drop_element')
178+
->fixXmlConfig('block_element')
179+
->fixXmlConfig('allow_attribute')
180+
->fixXmlConfig('drop_attribute')
181+
->children()
182+
->scalarNode('name')->isRequired()->end()
183+
->booleanNode('allow_safe_elements')->defaultValue(true)->end()
184+
->booleanNode('allow_static_elements')->defaultValue(true)->end()
185+
->arrayNode('allow_elements')->scalarPrototype()->end()->end()
186+
->arrayNode('block_elements')->scalarPrototype()->end()->end()
187+
->arrayNode('drop_elements')->scalarPrototype()->end()->end()
188+
->arrayNode('allow_attributes')->scalarPrototype()->end()->end()
189+
->arrayNode('drop_attributes')->scalarPrototype()->end()->end()
190+
->end()
191+
->end()
192+
->end()
193+
->end()
194+
->end()
165195
->scalarNode('default_code_language')->defaultValue('')->end()
166196
->arrayNode('themes')
167197
->defaultValue([])
@@ -290,6 +320,11 @@ public function load(array $configs, ContainerBuilder $container): void
290320
$container->setParameter('phpdoc.guides.base_template_paths', $config['base_template_paths']);
291321
$container->setParameter('phpdoc.guides.node_templates', $config['templates']);
292322
$container->setParameter('phpdoc.guides.inventories', $config['inventories']);
323+
$container->setParameter('phpdoc.guides.raw_node.escape', $config['raw_node']['escape'] ?? false);
324+
325+
if ($config['raw_node'] ?? false) {
326+
$this->configureSanitizers($config['raw_node'], $container);
327+
}
293328

294329
foreach ($config['themes'] as $themeName => $themeConfig) {
295330
$container->getDefinition(ThemeManager::class)
@@ -328,6 +363,50 @@ public function prepend(ContainerBuilder $container): void
328363
],
329364
);
330365
}
366+
367+
/** @param array<string, mixed> $rawNodeConfig */
368+
private function configureSanitizers(array $rawNodeConfig, ContainerBuilder $container): void
369+
{
370+
if ($rawNodeConfig['sanitizer_name'] ?? false) {
371+
$container->getDefinition(RawNodeEscapeTransformer::class)
372+
->setArgument('$htmlSanitizerConfig', new Reference('phpdoc.guides.raw_node.sanitizer.' . $rawNodeConfig['sanitizer_name']));
373+
}
374+
375+
foreach ($rawNodeConfig['sanitizers'] as $sanitizerConfig) {
376+
$def = $container->register('phpdoc.guides.raw_node.sanitizer.' . $sanitizerConfig['name'], HtmlSanitizerConfig::class);
377+
378+
// Base
379+
if ($sanitizerConfig['allow_safe_elements']) {
380+
$def->addMethodCall('allowSafeElements', [], true);
381+
}
382+
383+
if ($sanitizerConfig['allow_static_elements']) {
384+
$def->addMethodCall('allowStaticElements', [], true);
385+
}
386+
387+
// Configures elements
388+
foreach ($sanitizerConfig['allow_elements'] as $element => $attributes) {
389+
$def->addMethodCall('allowElement', [$element, $attributes], true);
390+
}
391+
392+
foreach ($sanitizerConfig['block_elements'] as $element) {
393+
$def->addMethodCall('blockElement', [$element], true);
394+
}
395+
396+
foreach ($sanitizerConfig['drop_elements'] as $element) {
397+
$def->addMethodCall('dropElement', [$element], true);
398+
}
399+
400+
// Configures attributes
401+
foreach ($sanitizerConfig['allow_attributes'] as $attribute => $elements) {
402+
$def->addMethodCall('allowAttribute', [$attribute, $elements], true);
403+
}
404+
405+
foreach ($sanitizerConfig['drop_attributes'] as $attribute => $elements) {
406+
$def->addMethodCall('dropAttribute', [$attribute, $elements], true);
407+
}
408+
}
409+
}
331410
}
332411

333412
/**

packages/guides/src/Nodes/RawNode.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,10 @@
1515

1616
final class RawNode extends TextNode
1717
{
18+
public function __construct(string $contents, string $format = 'html')
19+
{
20+
parent::__construct($contents);
21+
22+
$this->options['format'] = $format;
23+
}
1824
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!-- content start -->
2+
<div class="section" id="directive-tests">
3+
<h1>Directive tests</h1>
4+
<p>Lorem Ipsum Dolor</p>
5+
<p>Dolor sit!</p>
6+
</div>
7+
<!-- content end -->
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<guides xmlns="https://www.phpdoc.org/guides"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="https://www.phpdoc.org/guides packages/guides-cli/resources/schema/guides.xsd"
5+
input-format="rst"
6+
>
7+
<project title="Project Title" version="6.4"/>
8+
<raw-node sanitizer-name="custom" >
9+
<sanitizer name="custom">
10+
<allow-element>p</allow-element>
11+
<block-element>div</block-element>
12+
<drop-element>h2</drop-element>
13+
</sanitizer>
14+
</raw-node>
15+
</guides>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Directive tests
2+
===============
3+
4+
.. raw:: html
5+
6+
<div class="someClass">
7+
<script>alert('XSS');</script>
8+
<p>Lorem Ipsum Dolor</p>
9+
<h2>Some Rubric</h2>
10+
<p>Dolor sit!</p>
11+
</div>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!-- content start -->
2+
<div class="section" id="directive-tests">
3+
<h1>Directive tests</h1>
4+
<div>
5+
<p>Lorem Ipsum Dolor</p>
6+
<h2>Some Rubric</h2>
7+
<p>Dolor sit!</p>
8+
</div>
9+
</div>
10+
<!-- content end -->
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<guides xmlns="https://www.phpdoc.org/guides"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="https://www.phpdoc.org/guides packages/guides-cli/resources/schema/guides.xsd"
5+
input-format="rst"
6+
>
7+
<project title="Project Title" version="6.4"/>
8+
</guides>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Directive tests
2+
===============
3+
4+
.. raw:: html
5+
6+
<div class="someClass">
7+
<script>alert('XSS');</script>
8+
<p>Lorem Ipsum Dolor</p>
9+
<h2>Some Rubric</h2>
10+
<p>Dolor sit!</p>
11+
</div>

0 commit comments

Comments
 (0)