[增添]添加了datasource的setting数据库以及默认值

This commit is contained in:
makotocc0107
2024-08-27 09:57:44 +08:00
parent d111dfaea4
commit 72eb990970
10955 changed files with 978898 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
CHANGELOG
=========
6.4
---
* Add support for sanitizing unlimited length of HTML document
6.1
---
* Add the component as experimental

View File

@@ -0,0 +1,127 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\Parser\MastermindsParser;
use Symfony\Component\HtmlSanitizer\Parser\ParserInterface;
use Symfony\Component\HtmlSanitizer\Reference\W3CReference;
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
use Symfony\Component\HtmlSanitizer\Visitor\DomVisitor;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class HtmlSanitizer implements HtmlSanitizerInterface
{
private HtmlSanitizerConfig $config;
private ParserInterface $parser;
/**
* @var array<string, DomVisitor>
*/
private array $domVisitors = [];
public function __construct(HtmlSanitizerConfig $config, ?ParserInterface $parser = null)
{
$this->config = $config;
$this->parser = $parser ?? new MastermindsParser();
}
public function sanitize(string $input): string
{
return $this->sanitizeWithContext(W3CReference::CONTEXT_BODY, $input);
}
public function sanitizeFor(string $element, string $input): string
{
return $this->sanitizeWithContext(
W3CReference::CONTEXTS_MAP[StringSanitizer::htmlLower($element)] ?? W3CReference::CONTEXT_BODY,
$input
);
}
private function sanitizeWithContext(string $context, string $input): string
{
// Text context: early return with HTML encoding
if (W3CReference::CONTEXT_TEXT === $context) {
return StringSanitizer::encodeHtmlEntities($input);
}
// Other context: build a DOM visitor
$this->domVisitors[$context] ??= $this->createDomVisitorForContext($context);
// Prevent DOS attack induced by extremely long HTML strings
if (-1 !== $this->config->getMaxInputLength() && \strlen($input) > $this->config->getMaxInputLength()) {
$input = substr($input, 0, $this->config->getMaxInputLength());
}
// Only operate on valid UTF-8 strings. This is necessary to prevent cross
// site scripting issues on Internet Explorer 6. Idea from Drupal (filter_xss).
if (!$this->isValidUtf8($input)) {
return '';
}
// Remove NULL character
$input = str_replace(\chr(0), '', $input);
// Parse as HTML
if (!$parsed = $this->parser->parse($input)) {
return '';
}
// Visit the DOM tree and render the sanitized nodes
return $this->domVisitors[$context]->visit($parsed)?->render() ?? '';
}
private function isValidUtf8(string $html): bool
{
// preg_match() fails silently on strings containing invalid UTF-8.
return '' === $html || preg_match('//u', $html);
}
private function createDomVisitorForContext(string $context): DomVisitor
{
$elementsConfig = [];
// Head: only a few elements are allowed
if (W3CReference::CONTEXT_HEAD === $context) {
foreach ($this->config->getAllowedElements() as $allowedElement => $allowedAttributes) {
if (\array_key_exists($allowedElement, W3CReference::HEAD_ELEMENTS)) {
$elementsConfig[$allowedElement] = $allowedAttributes;
}
}
foreach ($this->config->getBlockedElements() as $blockedElement => $v) {
if (\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) {
$elementsConfig[$blockedElement] = false;
}
}
return new DomVisitor($this->config, $elementsConfig);
}
// Body: allow any configured element that isn't in <head>
foreach ($this->config->getAllowedElements() as $allowedElement => $allowedAttributes) {
if (!\array_key_exists($allowedElement, W3CReference::HEAD_ELEMENTS)) {
$elementsConfig[$allowedElement] = $allowedAttributes;
}
}
foreach ($this->config->getBlockedElements() as $blockedElement => $v) {
if (!\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) {
$elementsConfig[$blockedElement] = false;
}
}
return new DomVisitor($this->config, $elementsConfig);
}
}

View File

@@ -0,0 +1,507 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\Reference\W3CReference;
use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class HtmlSanitizerConfig
{
/**
* Elements that should be removed but their children should be retained.
*
* @var array<string, true>
*/
private array $blockedElements = [];
/**
* Elements that should be retained, with their allowed attributes.
*
* @var array<string, array<string, true>>
*/
private array $allowedElements = [];
/**
* Attributes that should always be added to certain elements.
*
* @var array<string, array<string, string>>
*/
private array $forcedAttributes = [];
/**
* Links schemes that should be retained, other being dropped.
*
* @var list<string>
*/
private array $allowedLinkSchemes = ['http', 'https', 'mailto', 'tel'];
/**
* Links hosts that should be retained (by default, all hosts are allowed).
*
* @var list<string>|null
*/
private ?array $allowedLinkHosts = null;
/**
* Should the sanitizer allow relative links (by default, they are dropped).
*/
private bool $allowRelativeLinks = false;
/**
* Image/Audio/Video schemes that should be retained, other being dropped.
*
* @var list<string>
*/
private array $allowedMediaSchemes = ['http', 'https', 'data'];
/**
* Image/Audio/Video hosts that should be retained (by default, all hosts are allowed).
*
* @var list<string>|null
*/
private ?array $allowedMediaHosts = null;
/**
* Should the sanitizer allow relative media URL (by default, they are dropped).
*/
private bool $allowRelativeMedias = false;
/**
* Should the URL in the sanitized document be transformed to HTTPS if they are using HTTP.
*/
private bool $forceHttpsUrls = false;
/**
* Sanitizers that should be applied to specific attributes in addition to standard sanitization.
*
* @var list<AttributeSanitizerInterface>
*/
private array $attributeSanitizers;
private int $maxInputLength = 20_000;
public function __construct()
{
$this->attributeSanitizers = [
new Visitor\AttributeSanitizer\UrlAttributeSanitizer(),
];
}
/**
* Allows all static elements and attributes from the W3C Sanitizer API standard.
*
* All scripts will be removed but the output may still contain other dangerous
* behaviors like CSS injection (click-jacking), CSS expressions, ...
*/
public function allowStaticElements(): static
{
$elements = array_merge(
array_keys(W3CReference::HEAD_ELEMENTS),
array_keys(W3CReference::BODY_ELEMENTS)
);
$clone = clone $this;
foreach ($elements as $element) {
$clone = $clone->allowElement($element, '*');
}
return $clone;
}
/**
* Allows "safe" elements and attributes.
*
* All scripts will be removed, as well as other dangerous behaviors like CSS injection.
*/
public function allowSafeElements(): static
{
$attributes = [];
foreach (W3CReference::ATTRIBUTES as $attribute => $isSafe) {
if ($isSafe) {
$attributes[] = $attribute;
}
}
$clone = clone $this;
foreach (W3CReference::HEAD_ELEMENTS as $element => $isSafe) {
if ($isSafe) {
$clone = $clone->allowElement($element, $attributes);
}
}
foreach (W3CReference::BODY_ELEMENTS as $element => $isSafe) {
if ($isSafe) {
$clone = $clone->allowElement($element, $attributes);
}
}
return $clone;
}
/**
* Allows only a given list of schemes to be used in links href attributes.
*
* All other schemes will be dropped.
*
* @param list<string> $allowLinkSchemes
*/
public function allowLinkSchemes(array $allowLinkSchemes): static
{
$clone = clone $this;
$clone->allowedLinkSchemes = $allowLinkSchemes;
return $clone;
}
/**
* Allows only a given list of hosts to be used in links href attributes.
*
* All other hosts will be dropped. By default all hosts are allowed
* ($allowedLinkHosts = null).
*
* @param list<string>|null $allowLinkHosts
*/
public function allowLinkHosts(?array $allowLinkHosts): static
{
$clone = clone $this;
$clone->allowedLinkHosts = $allowLinkHosts;
return $clone;
}
/**
* Allows relative URLs to be used in links href attributes.
*/
public function allowRelativeLinks(bool $allowRelativeLinks = true): static
{
$clone = clone $this;
$clone->allowRelativeLinks = $allowRelativeLinks;
return $clone;
}
/**
* Allows only a given list of schemes to be used in media source attributes (img, audio, video, ...).
*
* All other schemes will be dropped.
*
* @param list<string> $allowMediaSchemes
*/
public function allowMediaSchemes(array $allowMediaSchemes): static
{
$clone = clone $this;
$clone->allowedMediaSchemes = $allowMediaSchemes;
return $clone;
}
/**
* Allows only a given list of hosts to be used in media source attributes (img, audio, video, ...).
*
* All other hosts will be dropped. By default all hosts are allowed
* ($allowMediaHosts = null).
*
* @param list<string>|null $allowMediaHosts
*/
public function allowMediaHosts(?array $allowMediaHosts): static
{
$clone = clone $this;
$clone->allowedMediaHosts = $allowMediaHosts;
return $clone;
}
/**
* Allows relative URLs to be used in media source attributes (img, audio, video, ...).
*/
public function allowRelativeMedias(bool $allowRelativeMedias = true): static
{
$clone = clone $this;
$clone->allowRelativeMedias = $allowRelativeMedias;
return $clone;
}
/**
* Transforms URLs using the HTTP scheme to use the HTTPS scheme instead.
*/
public function forceHttpsUrls(bool $forceHttpsUrls = true): static
{
$clone = clone $this;
$clone->forceHttpsUrls = $forceHttpsUrls;
return $clone;
}
/**
* Configures the given element as allowed.
*
* Allowed elements are elements the sanitizer should retain from the input.
*
* A list of allowed attributes for this element can be passed as a second argument.
* Passing "*" will allow all standard attributes on this element. By default, no
* attributes are allowed on the element.
*
* @param list<string>|string $allowedAttributes
*/
public function allowElement(string $element, array|string $allowedAttributes = []): static
{
$clone = clone $this;
// Unblock the element is necessary
unset($clone->blockedElements[$element]);
$clone->allowedElements[$element] = [];
$attrs = ('*' === $allowedAttributes) ? array_keys(W3CReference::ATTRIBUTES) : (array) $allowedAttributes;
foreach ($attrs as $allowedAttr) {
$clone->allowedElements[$element][$allowedAttr] = true;
}
return $clone;
}
/**
* Configures the given element as blocked.
*
* Blocked elements are elements the sanitizer should remove from the input, but retain
* their children.
*/
public function blockElement(string $element): static
{
$clone = clone $this;
// Disallow the element is necessary
unset($clone->allowedElements[$element]);
$clone->blockedElements[$element] = true;
return $clone;
}
/**
* Configures the given element as dropped.
*
* Dropped elements are elements the sanitizer should remove from the input, including
* their children.
*
* Note: when using an empty configuration, all unknown elements are dropped
* automatically. This method let you drop elements that were allowed earlier
* in the configuration.
*/
public function dropElement(string $element): static
{
$clone = clone $this;
unset($clone->allowedElements[$element], $clone->blockedElements[$element]);
return $clone;
}
/**
* Configures the given attribute as allowed.
*
* Allowed attributes are attributes the sanitizer should retain from the input.
*
* A list of allowed elements for this attribute can be passed as a second argument.
* Passing "*" will allow all currently allowed elements to use this attribute.
*
* @param list<string>|string $allowedElements
*/
public function allowAttribute(string $attribute, array|string $allowedElements): static
{
$clone = clone $this;
$allowedElements = ('*' === $allowedElements) ? array_keys($clone->allowedElements) : (array) $allowedElements;
// For each configured element ...
foreach ($clone->allowedElements as $element => $attrs) {
if (\in_array($element, $allowedElements, true)) {
// ... if the attribute should be allowed, add it
$clone->allowedElements[$element][$attribute] = true;
} else {
// ... if the attribute should not be allowed, remove it
unset($clone->allowedElements[$element][$attribute]);
}
}
return $clone;
}
/**
* Configures the given attribute as dropped.
*
* Dropped attributes are attributes the sanitizer should remove from the input.
*
* A list of elements on which to drop this attribute can be passed as a second argument.
* Passing "*" will drop this attribute from all currently allowed elements.
*
* Note: when using an empty configuration, all unknown attributes are dropped
* automatically. This method let you drop attributes that were allowed earlier
* in the configuration.
*
* @param list<string>|string $droppedElements
*/
public function dropAttribute(string $attribute, array|string $droppedElements): static
{
$clone = clone $this;
$droppedElements = ('*' === $droppedElements) ? array_keys($clone->allowedElements) : (array) $droppedElements;
foreach ($droppedElements as $element) {
if (isset($clone->allowedElements[$element][$attribute])) {
unset($clone->allowedElements[$element][$attribute]);
}
}
return $clone;
}
/**
* Forcefully set the value of a given attribute on a given element.
*
* The attribute will be created on the nodes if it didn't exist.
*/
public function forceAttribute(string $element, string $attribute, string $value): static
{
$clone = clone $this;
$clone->forcedAttributes[$element][$attribute] = $value;
return $clone;
}
/**
* Registers a custom attribute sanitizer.
*/
public function withAttributeSanitizer(AttributeSanitizerInterface $sanitizer): static
{
$clone = clone $this;
$clone->attributeSanitizers[] = $sanitizer;
return $clone;
}
/**
* Unregisters a custom attribute sanitizer.
*/
public function withoutAttributeSanitizer(AttributeSanitizerInterface $sanitizer): static
{
$clone = clone $this;
$clone->attributeSanitizers = array_values(array_filter(
$this->attributeSanitizers,
static fn ($current) => $current !== $sanitizer
));
return $clone;
}
/**
* @param int $maxInputLength The maximum length of the input string in bytes
* -1 means no limit
*/
public function withMaxInputLength(int $maxInputLength): static
{
if ($maxInputLength < -1) {
throw new \InvalidArgumentException(sprintf('The maximum input length must be greater than -1, "%d" given.', $maxInputLength));
}
$clone = clone $this;
$clone->maxInputLength = $maxInputLength;
return $clone;
}
public function getMaxInputLength(): int
{
return $this->maxInputLength;
}
/**
* @return array<string, array<string, true>>
*/
public function getAllowedElements(): array
{
return $this->allowedElements;
}
/**
* @return array<string, true>
*/
public function getBlockedElements(): array
{
return $this->blockedElements;
}
/**
* @return array<string, array<string, string>>
*/
public function getForcedAttributes(): array
{
return $this->forcedAttributes;
}
/**
* @return list<string>
*/
public function getAllowedLinkSchemes(): array
{
return $this->allowedLinkSchemes;
}
/**
* @return list<string>|null
*/
public function getAllowedLinkHosts(): ?array
{
return $this->allowedLinkHosts;
}
public function getAllowRelativeLinks(): bool
{
return $this->allowRelativeLinks;
}
/**
* @return list<string>
*/
public function getAllowedMediaSchemes(): array
{
return $this->allowedMediaSchemes;
}
/**
* @return list<string>|null
*/
public function getAllowedMediaHosts(): ?array
{
return $this->allowedMediaHosts;
}
public function getAllowRelativeMedias(): bool
{
return $this->allowRelativeMedias;
}
public function getForceHttpsUrls(): bool
{
return $this->forceHttpsUrls;
}
/**
* @return list<AttributeSanitizerInterface>
*/
public function getAttributeSanitizers(): array
{
return $this->attributeSanitizers;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer;
/**
* Sanitizes an untrusted HTML input for safe insertion into a document's DOM.
*
* This interface is inspired by the W3C Standard Draft about a HTML Sanitizer API
* ({@see https://wicg.github.io/sanitizer-api/}).
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface HtmlSanitizerInterface
{
/**
* Sanitizes an untrusted HTML input for a <body> context.
*
* This method is NOT context sensitive: it assumes the returned HTML string
* will be injected in a "body" context, and therefore will drop tags only
* allowed in the "head" element. To sanitize a string for injection
* in the "head" element, use {@see HtmlSanitizerInterface::sanitizeFor()}.
*/
public function sanitize(string $input): string;
/**
* Sanitizes an untrusted HTML input for a given context.
*
* This method is context sensitive: by providing a parent element name
* (body, head, title, ...), the sanitizer will adapt its rules to only
* allow elements that are valid inside the given parent element.
*/
public function sanitizeFor(string $element, string $input): string;
}

19
vendor/symfony/html-sanitizer/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2021-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Parser;
use Masterminds\HTML5;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class MastermindsParser implements ParserInterface
{
public function __construct(private array $defaultOptions = [])
{
}
public function parse(string $html): ?\DOMNode
{
return (new HTML5($this->defaultOptions))->loadHTMLFragment($html);
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Parser;
/**
* Transforms an untrusted HTML input string into a DOM tree.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface ParserInterface
{
/**
* Parse a given string and returns a DOMNode tree.
*
* This method must return null if the string cannot be parsed as HTML.
*/
public function parse(string $html): ?\DOMNode;
}

115
vendor/symfony/html-sanitizer/README.md vendored Normal file
View File

@@ -0,0 +1,115 @@
HtmlSanitizer Component
=======================
The HtmlSanitizer component provides an object-oriented API to sanitize
untrusted HTML input for safe insertion into a document's DOM.
Usage
-----
```php
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
// By default, an element not added to the allowed or blocked elements
// will be dropped, including its children
$config = (new HtmlSanitizerConfig())
// Allow "safe" elements and attributes. All scripts will be removed
// as well as other dangerous behaviors like CSS injection
->allowSafeElements()
// Allow all static elements and attributes from the W3C Sanitizer API
// standard. All scripts will be removed but the output may still contain
// other dangerous behaviors like CSS injection (click-jacking), CSS
// expressions, ...
->allowStaticElements()
// Allow the "div" element and no attribute can be on it
->allowElement('div')
// Allow the "a" element, and the "title" attribute to be on it
->allowElement('a', ['title'])
// Allow the "span" element, and any attribute from the Sanitizer API is allowed
// (see https://wicg.github.io/sanitizer-api/#default-configuration)
->allowElement('span', '*')
// Block the "section" element: this element will be removed but
// its children will be retained
->blockElement('section')
// Drop the "div" element: this element will be removed, including its children
->dropElement('div')
// Allow the attribute "title" on the "div" element
->allowAttribute('title', ['div'])
// Allow the attribute "data-custom-attr" on all currently allowed elements
->allowAttribute('data-custom-attr', '*')
// Drop the "data-custom-attr" attribute from the "div" element:
// this attribute will be removed
->dropAttribute('data-custom-attr', ['div'])
// Drop the "data-custom-attr" attribute from all elements:
// this attribute will be removed
->dropAttribute('data-custom-attr', '*')
// Forcefully set the value of all "rel" attributes on "a"
// elements to "noopener noreferrer"
->forceAttribute('a', 'rel', 'noopener noreferrer')
// Transform all HTTP schemes to HTTPS
->forceHttpsUrls()
// Configure which schemes are allowed in links (others will be dropped)
->allowLinkSchemes(['https', 'http', 'mailto'])
// Configure which hosts are allowed in links (by default all are allowed)
->allowLinkHosts(['symfony.com', 'example.com'])
// Allow relative URL in links (by default they are dropped)
->allowRelativeLinks()
// Configure which schemes are allowed in img/audio/video/iframe (others will be dropped)
->allowMediaSchemes(['https', 'http'])
// Configure which hosts are allowed in img/audio/video/iframe (by default all are allowed)
->allowMediaHosts(['symfony.com', 'example.com'])
// Allow relative URL in img/audio/video/iframe (by default they are dropped)
->allowRelativeMedias()
// Configure a custom attribute sanitizer to apply custom sanitization logic
// ($attributeSanitizer instance of AttributeSanitizerInterface)
->withAttributeSanitizer($attributeSanitizer)
// Unregister a previously registered attribute sanitizer
// ($attributeSanitizer instance of AttributeSanitizerInterface)
->withoutAttributeSanitizer($attributeSanitizer)
;
$sanitizer = new HtmlSanitizer($config);
// Sanitize a given string, using the configuration provided and in the
// "body" context (tags only allowed in <head> will be removed)
$sanitizer->sanitize($userInput);
// Sanitize the given string for a usage in a <head> tag
$sanitizer->sanitizeFor('head', $userInput);
// Sanitize the given string for a usage in another tag
$sanitizer->sanitizeFor('title', $userInput); // Will encode as HTML entities
$sanitizer->sanitizeFor('textarea', $userInput); // Will encode as HTML entities
$sanitizer->sanitizeFor('div', $userInput); // Will sanitize as body
$sanitizer->sanitizeFor('section', $userInput); // Will sanitize as body
// ...
```
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@@ -0,0 +1,400 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Reference;
/**
* Stores reference data from the W3C Sanitizer API standard.
*
* @see https://wicg.github.io/sanitizer-api/#default-configuration
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*
* @internal
*/
final class W3CReference
{
/**
* Sanitizer supported contexts.
*
* A parent element name can be passed as an argument to {@see HtmlSanitizer::sanitizeFor()}.
* When doing so, depending on the given context, different elements will be allowed.
*/
public const CONTEXT_HEAD = 'head';
public const CONTEXT_BODY = 'body';
public const CONTEXT_TEXT = 'text';
// Which context to apply depending on the passed parent element name
public const CONTEXTS_MAP = [
'head' => self::CONTEXT_HEAD,
'textarea' => self::CONTEXT_TEXT,
'title' => self::CONTEXT_TEXT,
];
/**
* Elements allowed by the Sanitizer standard in <head> as keys, including whether
* they are safe or not as values (safe meaning no global display/audio/video impact).
*/
public const HEAD_ELEMENTS = [
'head' => true,
'link' => true,
'meta' => true,
'style' => false,
'title' => true,
];
/**
* Elements allowed by the Sanitizer standard in <body> as keys, including whether
* they are safe or not as values (safe meaning no global display/audio/video impact).
*/
public const BODY_ELEMENTS = [
'a' => true,
'abbr' => true,
'acronym' => true,
'address' => true,
'area' => true,
'article' => true,
'aside' => true,
'audio' => true,
'b' => true,
'basefont' => true,
'bdi' => true,
'bdo' => true,
'bgsound' => false,
'big' => true,
'blockquote' => true,
'body' => true,
'br' => true,
'button' => true,
'canvas' => true,
'caption' => true,
'center' => true,
'cite' => true,
'code' => true,
'col' => true,
'colgroup' => true,
'command' => true,
'data' => true,
'datalist' => true,
'dd' => true,
'del' => true,
'details' => true,
'dfn' => true,
'dialog' => true,
'dir' => true,
'div' => true,
'dl' => true,
'dt' => true,
'em' => true,
'fieldset' => true,
'figcaption' => true,
'figure' => true,
'font' => true,
'footer' => true,
'form' => false,
'h1' => true,
'h2' => true,
'h3' => true,
'h4' => true,
'h5' => true,
'h6' => true,
'header' => true,
'hgroup' => true,
'hr' => true,
'html' => true,
'i' => true,
'image' => true,
'img' => true,
'input' => false,
'ins' => true,
'kbd' => true,
'keygen' => true,
'label' => true,
'layer' => true,
'legend' => true,
'li' => true,
'listing' => true,
'main' => true,
'map' => true,
'mark' => true,
'marquee' => true,
'menu' => true,
'meter' => true,
'nav' => true,
'nobr' => true,
'ol' => true,
'optgroup' => true,
'option' => true,
'output' => true,
'p' => true,
'picture' => true,
'plaintext' => true,
'popup' => true,
'portal' => true,
'pre' => true,
'progress' => true,
'q' => true,
'rb' => true,
'rp' => true,
'rt' => true,
'rtc' => true,
'ruby' => true,
's' => true,
'samp' => true,
'section' => true,
'select' => false,
'selectmenu' => false,
'slot' => true,
'small' => true,
'source' => true,
'span' => true,
'strike' => true,
'strong' => true,
'sub' => true,
'summary' => true,
'sup' => true,
'table' => true,
'tbody' => true,
'td' => true,
'template' => true,
'textarea' => false,
'tfoot' => true,
'th' => true,
'thead' => true,
'time' => true,
'tr' => true,
'track' => true,
'tt' => true,
'u' => true,
'ul' => true,
'var' => true,
'video' => true,
'wbr' => true,
'xmp' => true,
];
/**
* Attributes allowed by the standard.
*/
public const ATTRIBUTES = [
'abbr' => true,
'accept' => true,
'accept-charset' => true,
'accesskey' => true,
'action' => true,
'align' => true,
'alink' => true,
'allow' => true,
'allowfullscreen' => true,
'allowpaymentrequest' => false,
'alt' => true,
'anchor' => true,
'archive' => true,
'as' => true,
'async' => false,
'autocapitalize' => false,
'autocomplete' => false,
'autocorrect' => false,
'autofocus' => false,
'autopictureinpicture' => false,
'autoplay' => false,
'axis' => true,
'background' => false,
'behavior' => true,
'bgcolor' => false,
'border' => false,
'bordercolor' => false,
'capture' => true,
'cellpadding' => true,
'cellspacing' => true,
'challenge' => true,
'char' => true,
'charoff' => true,
'charset' => true,
'checked' => false,
'cite' => true,
'class' => false,
'classid' => false,
'clear' => true,
'code' => true,
'codebase' => true,
'codetype' => true,
'color' => false,
'cols' => true,
'colspan' => true,
'compact' => true,
'content' => true,
'contenteditable' => false,
'controls' => true,
'controlslist' => true,
'conversiondestination' => true,
'coords' => true,
'crossorigin' => true,
'csp' => true,
'data' => true,
'datetime' => true,
'declare' => true,
'decoding' => true,
'default' => true,
'defer' => true,
'dir' => true,
'direction' => true,
'dirname' => true,
'disabled' => true,
'disablepictureinpicture' => true,
'disableremoteplayback' => true,
'disallowdocumentaccess' => true,
'download' => true,
'draggable' => true,
'elementtiming' => true,
'enctype' => true,
'end' => true,
'enterkeyhint' => true,
'event' => true,
'exportparts' => true,
'face' => true,
'for' => true,
'form' => false,
'formaction' => false,
'formenctype' => false,
'formmethod' => false,
'formnovalidate' => false,
'formtarget' => false,
'frame' => false,
'frameborder' => false,
'headers' => true,
'height' => true,
'hidden' => false,
'high' => true,
'href' => true,
'hreflang' => true,
'hreftranslate' => true,
'hspace' => true,
'http-equiv' => false,
'id' => true,
'imagesizes' => true,
'imagesrcset' => true,
'importance' => true,
'impressiondata' => true,
'impressionexpiry' => true,
'incremental' => true,
'inert' => true,
'inputmode' => true,
'integrity' => true,
'invisible' => true,
'is' => true,
'ismap' => true,
'keytype' => true,
'kind' => true,
'label' => true,
'lang' => true,
'language' => true,
'latencyhint' => true,
'leftmargin' => true,
'link' => true,
'list' => true,
'loading' => true,
'longdesc' => true,
'loop' => true,
'low' => true,
'lowsrc' => true,
'manifest' => true,
'marginheight' => true,
'marginwidth' => true,
'max' => true,
'maxlength' => true,
'mayscript' => true,
'media' => true,
'method' => true,
'min' => true,
'minlength' => true,
'multiple' => true,
'muted' => true,
'name' => true,
'nohref' => true,
'nomodule' => true,
'nonce' => true,
'noresize' => true,
'noshade' => true,
'novalidate' => true,
'nowrap' => true,
'object' => true,
'open' => true,
'optimum' => true,
'part' => true,
'pattern' => true,
'ping' => false,
'placeholder' => true,
'playsinline' => true,
'policy' => true,
'poster' => true,
'preload' => true,
'pseudo' => true,
'readonly' => true,
'referrerpolicy' => true,
'rel' => true,
'reportingorigin' => true,
'required' => true,
'resources' => true,
'rev' => true,
'reversed' => true,
'role' => true,
'rows' => true,
'rowspan' => true,
'rules' => true,
'sandbox' => true,
'scheme' => true,
'scope' => true,
'scopes' => true,
'scrollamount' => true,
'scrolldelay' => true,
'scrolling' => true,
'select' => false,
'selected' => false,
'shadowroot' => true,
'shadowrootdelegatesfocus' => true,
'shape' => true,
'size' => true,
'sizes' => true,
'slot' => true,
'span' => true,
'spellcheck' => true,
'src' => true,
'srcdoc' => true,
'srclang' => true,
'srcset' => true,
'standby' => true,
'start' => true,
'step' => true,
'style' => false,
'summary' => true,
'tabindex' => true,
'target' => true,
'text' => true,
'title' => true,
'topmargin' => true,
'translate' => true,
'truespeed' => true,
'trusttoken' => true,
'type' => true,
'usemap' => true,
'valign' => true,
'value' => false,
'valuetype' => true,
'version' => true,
'virtualkeyboardpolicy' => true,
'vlink' => false,
'vspace' => true,
'webkitdirectory' => true,
'width' => true,
'wrap' => true,
];
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\TextSanitizer;
/**
* @internal
*/
final class StringSanitizer
{
private const LOWERCASE = [
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz',
];
private const REPLACEMENTS = [
[
// "&#34;" is shorter than "&quot;"
'&quot;',
// Fix several potential issues in how browsers interpret attributes values
'+',
'=',
'@',
'`',
// Some DB engines will transform UTF8 full-width characters their classical version
// if the data is saved in a non-UTF8 field
'',
'',
'',
'',
'',
'',
],
[
'&#34;',
'&#43;',
'&#61;',
'&#64;',
'&#96;',
'&#xFF1C;',
'&#xFF1E;',
'&#xFF0B;',
'&#xFF1D;',
'&#xFF20;',
'&#xFF40;',
],
];
/**
* Applies a transformation to lowercase following W3C HTML Standard.
*
* @see https://w3c.github.io/html-reference/terminology.html#case-insensitive
*/
public static function htmlLower(string $string): string
{
return strtr($string, self::LOWERCASE[0], self::LOWERCASE[1]);
}
/**
* Encodes the HTML entities in the given string for safe injection in a document's DOM.
*/
public static function encodeHtmlEntities(string $string): string
{
return str_replace(
self::REPLACEMENTS[0],
self::REPLACEMENTS[1],
htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8')
);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\TextSanitizer;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\UriString;
/**
* @internal
*/
final class UrlSanitizer
{
/**
* Sanitizes a given URL string.
*
* In addition to ensuring $input is a valid URL, this sanitizer checks that:
* * the URL's host is allowed ;
* * the URL's scheme is allowed ;
* * the URL is allowed to be relative if it is ;
*
* It also transforms the URL to HTTPS if requested.
*/
public static function sanitize(?string $input, ?array $allowedSchemes = null, bool $forceHttps = false, ?array $allowedHosts = null, bool $allowRelative = false): ?string
{
if (!$input) {
return null;
}
$url = self::parse($input);
// Malformed URL
if (!$url || !\is_array($url)) {
return null;
}
// No scheme and relative not allowed
if (!$allowRelative && !$url['scheme']) {
return null;
}
// Forbidden scheme
if ($url['scheme'] && null !== $allowedSchemes && !\in_array($url['scheme'], $allowedSchemes, true)) {
return null;
}
// If the scheme used is not supposed to have a host, do not check the host
if (!self::isHostlessScheme($url['scheme'])) {
// No host and relative not allowed
if (!$allowRelative && !$url['host']) {
return null;
}
// Forbidden host
if ($url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) {
return null;
}
}
// Force HTTPS
if ($forceHttps && 'http' === $url['scheme']) {
$url['scheme'] = 'https';
}
return UriString::build($url);
}
/**
* Parses a given URL and returns an array of its components.
*
* @return null|array{
* scheme:?string,
* user:?string,
* pass:?string,
* host:?string,
* port:?int,
* path:string,
* query:?string,
* fragment:?string
* }
*/
public static function parse(string $url): ?array
{
if (!$url) {
return null;
}
try {
return UriString::parse($url);
} catch (SyntaxError) {
return null;
}
}
private static function isHostlessScheme(?string $scheme): bool
{
return \in_array($scheme, ['blob', 'chrome', 'data', 'file', 'geo', 'mailto', 'maps', 'tel', 'view-source'], true);
}
private static function isAllowedHost(?string $host, array $allowedHosts): bool
{
if (null === $host) {
return \in_array(null, $allowedHosts, true);
}
$parts = array_reverse(explode('.', $host));
foreach ($allowedHosts as $allowedHost) {
if (self::matchAllowedHostParts($parts, array_reverse(explode('.', $allowedHost)))) {
return true;
}
}
return false;
}
private static function matchAllowedHostParts(array $uriParts, array $trustedParts): bool
{
// Check each chunk of the domain is valid
foreach ($trustedParts as $key => $trustedPart) {
if ($uriParts[$key] !== $trustedPart) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
/**
* Implements attribute-specific sanitization logic.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface AttributeSanitizerInterface
{
/**
* Returns the list of element names supported, or null to support all elements.
*
* @return list<string>|null
*/
public function getSupportedElements(): ?array;
/**
* Returns the list of attributes names supported, or null to support all attributes.
*
* @return list<string>|null
*/
public function getSupportedAttributes(): ?array;
/**
* Returns the sanitized value of a given attribute for the given element.
*/
public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string;
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\TextSanitizer\UrlSanitizer;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class UrlAttributeSanitizer implements AttributeSanitizerInterface
{
public function getSupportedElements(): ?array
{
// Check all elements for URL attributes
return null;
}
public function getSupportedAttributes(): ?array
{
return ['src', 'href', 'lowsrc', 'background', 'ping'];
}
public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string
{
if ('a' === $element) {
return UrlSanitizer::sanitize(
$value,
$config->getAllowedLinkSchemes(),
$config->getForceHttpsUrls(),
$config->getAllowedLinkHosts(),
$config->getAllowRelativeLinks(),
);
}
return UrlSanitizer::sanitize(
$value,
$config->getAllowedMediaSchemes(),
$config->getForceHttpsUrls(),
$config->getAllowedMediaHosts(),
$config->getAllowRelativeMedias(),
);
}
}

View File

@@ -0,0 +1,177 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
use Symfony\Component\HtmlSanitizer\Visitor\Model\Cursor;
use Symfony\Component\HtmlSanitizer\Visitor\Node\BlockedNode;
use Symfony\Component\HtmlSanitizer\Visitor\Node\DocumentNode;
use Symfony\Component\HtmlSanitizer\Visitor\Node\Node;
use Symfony\Component\HtmlSanitizer\Visitor\Node\NodeInterface;
use Symfony\Component\HtmlSanitizer\Visitor\Node\TextNode;
/**
* Iterates over the parsed DOM tree to build the sanitized tree.
*
* The DomVisitor iterates over the parsed DOM tree, visits its nodes and build
* a sanitized tree with their attributes and content.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*
* @internal
*/
final class DomVisitor
{
private HtmlSanitizerConfig $config;
/**
* Registry of allowed/blocked elements:
* * If an element is present as a key and contains an array, the element should be allowed
* and the array is the list of allowed attributes.
* * If an element is present as a key and contains "false", the element should be blocked.
* * If an element is not present as a key, the element should be dropped.
*
* @var array<string, false|array<string, bool>>
*/
private array $elementsConfig;
/**
* Registry of attributes to forcefully set on nodes, index by element and attribute.
*
* @var array<string, array<string, string>>
*/
private array $forcedAttributes;
/**
* Registry of attributes sanitizers indexed by element name and attribute name for
* faster sanitization.
*
* @var array<string, array<string, list<AttributeSanitizerInterface>>>
*/
private array $attributeSanitizers = [];
/**
* @param array<string, false|array<string, bool>> $elementsConfig
*/
public function __construct(HtmlSanitizerConfig $config, array $elementsConfig)
{
$this->config = $config;
$this->elementsConfig = $elementsConfig;
$this->forcedAttributes = $config->getForcedAttributes();
foreach ($config->getAttributeSanitizers() as $attributeSanitizer) {
foreach ($attributeSanitizer->getSupportedElements() ?? ['*'] as $element) {
foreach ($attributeSanitizer->getSupportedAttributes() ?? ['*'] as $attribute) {
$this->attributeSanitizers[$element][$attribute][] = $attributeSanitizer;
}
}
}
}
public function visit(\DOMDocumentFragment $domNode): ?NodeInterface
{
$cursor = new Cursor(new DocumentNode());
$this->visitChildren($domNode, $cursor);
return $cursor->node;
}
private function visitNode(\DOMNode $domNode, Cursor $cursor): void
{
$nodeName = StringSanitizer::htmlLower($domNode->nodeName);
// Element should be dropped, including its children
if (!\array_key_exists($nodeName, $this->elementsConfig)) {
return;
}
// Otherwise, visit recursively
$this->enterNode($nodeName, $domNode, $cursor);
$this->visitChildren($domNode, $cursor);
$cursor->node = $cursor->node->getParent();
}
private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): void
{
// Element should be blocked, retaining its children
if (false === $this->elementsConfig[$domNodeName]) {
$node = new BlockedNode($cursor->node);
$cursor->node->addChild($node);
$cursor->node = $node;
return;
}
// Otherwise create the node
$node = new Node($cursor->node, $domNodeName);
$this->setAttributes($domNodeName, $domNode, $node, $this->elementsConfig[$domNodeName]);
// Force configured attributes
foreach ($this->forcedAttributes[$domNodeName] ?? [] as $attribute => $value) {
$node->setAttribute($attribute, $value);
}
$cursor->node->addChild($node);
$cursor->node = $node;
}
private function visitChildren(\DOMNode $domNode, Cursor $cursor): void
{
/** @var \DOMNode $child */
foreach ($domNode->childNodes ?? [] as $child) {
if ('#text' === $child->nodeName) {
// Add text directly for performance
$cursor->node->addChild(new TextNode($cursor->node, $child->nodeValue));
} elseif (!$child instanceof \DOMText && !$child instanceof \DOMProcessingInstruction) {
// Otherwise continue the visit recursively
// Ignore comments for security reasons (interpreted differently by browsers)
// Ignore processing instructions (treated as comments)
$this->visitNode($child, $cursor);
}
}
}
/**
* Set attributes from a DOM node to a sanitized node.
*/
private function setAttributes(string $domNodeName, \DOMNode $domNode, Node $node, array $allowedAttributes = []): void
{
/** @var iterable<\DOMAttr> $domAttributes */
if (!$domAttributes = $domNode->attributes ? $domNode->attributes->getIterator() : []) {
return;
}
foreach ($domAttributes as $attribute) {
$name = StringSanitizer::htmlLower($attribute->name);
if (isset($allowedAttributes[$name])) {
$value = $attribute->value;
// Sanitize the attribute value if there are attribute sanitizers for it
$attributeSanitizers = array_merge(
$this->attributeSanitizers[$domNodeName][$name] ?? [],
$this->attributeSanitizers['*'][$name] ?? [],
$this->attributeSanitizers[$domNodeName]['*'] ?? [],
);
foreach ($attributeSanitizers as $sanitizer) {
$value = $sanitizer->sanitizeAttribute($domNodeName, $name, $value, $this->config);
}
$node->setAttribute($name, $value);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Model;
use Symfony\Component\HtmlSanitizer\Visitor\Node\NodeInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*
* @internal
*/
final class Cursor
{
public function __construct(public ?NodeInterface $node)
{
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class BlockedNode implements NodeInterface
{
private NodeInterface $parentNode;
private array $children = [];
public function __construct(NodeInterface $parentNode)
{
$this->parentNode = $parentNode;
}
public function addChild(NodeInterface $node): void
{
$this->children[] = $node;
}
public function getParent(): ?NodeInterface
{
return $this->parentNode;
}
public function render(): string
{
$rendered = '';
foreach ($this->children as $child) {
$rendered .= $child->render();
}
return $rendered;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class DocumentNode implements NodeInterface
{
private array $children = [];
public function addChild(NodeInterface $node): void
{
$this->children[] = $node;
}
public function getParent(): ?NodeInterface
{
return null;
}
public function render(): string
{
$rendered = '';
foreach ($this->children as $child) {
$rendered .= $child->render();
}
return $rendered;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class Node implements NodeInterface
{
// HTML5 elements which are self-closing
private const VOID_ELEMENTS = [
'area' => true,
'base' => true,
'br' => true,
'col' => true,
'embed' => true,
'hr' => true,
'img' => true,
'input' => true,
'keygen' => true,
'link' => true,
'meta' => true,
'param' => true,
'source' => true,
'track' => true,
'wbr' => true,
];
private NodeInterface $parent;
private string $tagName;
private array $attributes = [];
private array $children = [];
public function __construct(NodeInterface $parent, string $tagName)
{
$this->parent = $parent;
$this->tagName = $tagName;
}
public function getParent(): ?NodeInterface
{
return $this->parent;
}
public function getAttribute(string $name): ?string
{
return $this->attributes[$name] ?? null;
}
public function setAttribute(string $name, ?string $value): void
{
// Always use only the first declaration (ease sanitization)
if (!\array_key_exists($name, $this->attributes)) {
$this->attributes[$name] = $value;
}
}
public function addChild(NodeInterface $node): void
{
$this->children[] = $node;
}
public function render(): string
{
if (isset(self::VOID_ELEMENTS[$this->tagName])) {
return '<'.$this->tagName.$this->renderAttributes().' />';
}
$rendered = '<'.$this->tagName.$this->renderAttributes().'>';
foreach ($this->children as $child) {
$rendered .= $child->render();
}
return $rendered.'</'.$this->tagName.'>';
}
private function renderAttributes(): string
{
$rendered = [];
foreach ($this->attributes as $name => $value) {
if (null === $value) {
// Tag should be removed as a sanitizer found suspect data inside
continue;
}
$attr = StringSanitizer::encodeHtmlEntities($name);
if ('' !== $value) {
// In quirks mode, IE8 does a poor job producing innerHTML values.
// If JavaScript does:
// nodeA.innerHTML = nodeB.innerHTML;
// and nodeB contains (or even if ` was encoded properly):
// <div attr="``foo=bar">
// then IE8 will produce:
// <div attr=``foo=bar>
// as the value of nodeB.innerHTML and assign it to nodeA.
// IE8's HTML parser treats `` as a blank attribute value and foo=bar becomes a separate attribute.
// Adding a space at the end of the attribute prevents this by forcing IE8 to put double
// quotes around the attribute when computing nodeB.innerHTML.
if (str_contains($value, '`')) {
$value .= ' ';
}
$attr .= '="'.StringSanitizer::encodeHtmlEntities($value).'"';
}
$rendered[] = $attr;
}
return $rendered ? ' '.implode(' ', $rendered) : '';
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
/**
* Represents the sanitized version of a DOM node in the sanitized tree.
*
* Once the sanitization is done, nodes are rendered into the final output string.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface NodeInterface
{
/**
* Add a child node to this node.
*/
public function addChild(self $node): void;
/**
* Return the parent node of this node, or null if it has no parent node.
*/
public function getParent(): ?self;
/**
* Render this node as a string, recursively rendering its children as well.
*/
public function render(): string;
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
final class TextNode implements NodeInterface
{
public function __construct(private NodeInterface $parentNode, private string $text)
{
}
public function addChild(NodeInterface $node): void
{
throw new \LogicException('Text nodes cannot have children.');
}
public function getParent(): ?NodeInterface
{
return $this->parentNode;
}
public function render(): string
{
return StringSanitizer::encodeHtmlEntities($this->text);
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "symfony/html-sanitizer",
"type": "library",
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
"keywords": ["html", "sanitizer", "purifier"],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.2",
"ext-dom": "*",
"league/uri": "^6.5|^7.0",
"masterminds/html5": "^2.7.2"
},
"autoload": {
"psr-4": { "Symfony\\Component\\HtmlSanitizer\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}