org.xwiki.commons:xwiki-commons-xml Cross-site Scripting vulnerability
Description
XWiki Commons HTML cleaner restricted mode fails to filter dangerous tags/attributes, allowing stored XSS leading to RCE for privileged users.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
XWiki Commons HTML cleaner restricted mode fails to filter dangerous tags/attributes, allowing stored XSS leading to RCE for privileged users.
The XWiki Commons HTML cleaner's "restricted" mode, intended to strip unsafe HTML from user-contributed content, only escaped ` and tags, neglecting dangerous attributes like onclick and other elements such as [1]. This oversight means that any application relying on this mode for security (e.g., comments processed by the {{html}}` macro) is vulnerable to stored cross-site scripting (XSS) [4].
Exploitation
An attacker can inject malicious HTML or JavaScript via attributes (e.g., ``) or dangerous elements [4]. When a privileged user with programming rights views the crafted content, the script executes in their session, bypassing the restricted-mode sanitization [1]. No authentication is required for the attacker to place the payload, but the XSS triggers only when a privileged user accesses the compromised page.
Impact
Successful exploitation allows the attacker to execute arbitrary JavaScript in the context of a privileged user's session. Because programming rights in XWiki enable server-side operations, the attacker can escalate to remote code execution (RCE) on the XWiki instance, compromising its confidentiality, integrity, and availability [1].
Mitigation
The vulnerability is patched in XWiki 14.6 RC1, which introduces a filter that defines a whitelist of safe HTML elements and attributes, enabled in restricted mode [2][3]. No workarounds exist; upgrading to a fixed version is required [1].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.xwiki.commons:xwiki-commons-xmlMaven | >= 4.2-milestone-1, < 14.6-rc-1 | 14.6-rc-1 |
Affected products
2- Range: >= 4.2-milestone-1, < 14.6-rc-1
Patches
2b11eae9d82cbXCOMMONS-1680: Filter Html attributes in restricted mode based on a whitelist
6 files changed · +343 −13
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/DefaultHTMLCleaner.java+6 −1 modified@@ -107,6 +107,10 @@ public class DefaultHTMLCleaner implements HTMLCleaner // TODO: remove when upgrading to HTMLClener 2.23 private HTMLFilter controlFilter; + @Inject + @Named("sanitizer") + private HTMLFilter sanitizerFilter; + @Inject private Execution execution; @@ -201,7 +205,8 @@ public HTMLCleanerConfiguration getDefaultConfiguration() this.listFilter, this.fontFilter, this.attributeFilter, - this.linkFilter)); + this.linkFilter, + this.sanitizerFilter)); return configuration; }
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/filter/SanitizerFilter.java+246 −0 added@@ -0,0 +1,246 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +/* + * Alternatively, at your choice, the contents of this file may be used under the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package org.xwiki.xml.internal.html.filter; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.xwiki.component.annotation.Component; +import org.xwiki.xml.html.HTMLCleanerConfiguration; +import org.xwiki.xml.html.HTMLElementSanitizer; +import org.xwiki.xml.html.filter.AbstractHTMLFilter; +import org.xwiki.xml.internal.html.MathMLDefinitions; +import org.xwiki.xml.internal.html.SVGDefinitions; + +/** + * Sanitizer that sanitizes the document. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component +@Named("sanitizer") +@Singleton +public class SanitizerFilter extends AbstractHTMLFilter +{ + private static final String MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML"; + + private static final String SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + + private static final String HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + + @Inject + private HTMLElementSanitizer htmlElementSanitizer; + + @Inject + private SVGDefinitions svgDefinitions; + + @Inject + private MathMLDefinitions mathMLDefinitions; + + @Override + public void filter(Document document, Map<String, String> cleaningParameters) + { + String restricted = cleaningParameters.get(HTMLCleanerConfiguration.RESTRICTED); + if ("true".equalsIgnoreCase(restricted)) { + cleanDocument(document.getDocumentElement()); + } + } + + private static class TagInformation + { + public static final TagInformation INVALID = new TagInformation(null, null); + + public final String tagName; + + public final String namespace; + + /** + * Default constructor. + * + * @param tagName the name of the tag + * @param namespace the namespace of the tag + */ + TagInformation(String tagName, String namespace) + { + this.tagName = tagName; + this.namespace = namespace; + } + } + + private void cleanDocument(Element rootElement) + { + List<Element> elementsToRemove = new ArrayList<>(); + traverseWithNamespace(rootElement, (element, currentNamespace) -> { + if (currentNamespace == TagInformation.INVALID + || !this.htmlElementSanitizer.isElementAllowed(element.getTagName())) + { + elementsToRemove.add(element); + return true; + } else { + getAttributes(element).stream() + .filter( + attr -> !this.htmlElementSanitizer.isAttributeAllowed(element.getTagName(), attr.getName(), + attr.getValue()) + ) + .forEach(element::removeAttributeNode); + return false; + } + }); + + elementsToRemove.forEach(element -> element.getParentNode().removeChild(element)); + } + + private void traverseWithNamespace(Element rootElement, BiPredicate<Element, TagInformation> traversal) + { + Node node = rootElement; + + boolean reachedRoot = false; + + Deque<TagInformation> parentNamespace = new ArrayDeque<>(); + TagInformation currentNamespace = new TagInformation("html", HTML_NAMESPACE); + parentNamespace.push(currentNamespace); + + while (!reachedRoot) { + boolean skipChildren = false; + + if (node.getNodeType() == Node.ELEMENT_NODE && node instanceof Element) { + Element element = (Element) node; + + currentNamespace = checkNamespace(element, parentNamespace.peek()); + skipChildren = traversal.test(element, currentNamespace); + } + + if (node.getFirstChild() != null && !skipChildren) { + node = node.getFirstChild(); + parentNamespace.push(currentNamespace); + } else { + while (node.getNextSibling() == null) { + if (node == rootElement) { + reachedRoot = true; + break; + } + + node = node.getParentNode(); + currentNamespace = parentNamespace.pop(); + } + + node = node.getNextSibling(); + } + } + } + + /** + * Computes the namespace of the current element if it is allowed. + * <p> + * Tries to follow the logic in DOMPurify by Cure53 and other contributors | Released under the Apache license + * 2.0 and Mozilla Public License 2.0 - <a href="https://github.com/cure53/DOMPurify/blob/main/LICENSE">LICENSE</a>. + * + * @param element the element to check + * @param parentTag the information of the parent tag + * @return the tag information of the current tag or {@link TagInformation#INVALID} if the element must not be + * there + */ + private TagInformation checkNamespace(Element element, TagInformation parentTag) + { + TagInformation result = TagInformation.INVALID; + + // Stay in parent SVG/MathML namespace if the current element clearly belongs to the parent namespace. + if (SVG_NAMESPACE.equals(parentTag.namespace) && isPureSVGTag(element.getTagName(), parentTag)) { + result = new TagInformation(element.getTagName(), SVG_NAMESPACE); + } else if (MATHML_NAMESPACE.equals(parentTag.namespace) + && this.mathMLDefinitions.isMathMLTag(element.getTagName())) + { + result = new TagInformation(element.getTagName(), MATHML_NAMESPACE); + } else if (areHTMLChildrenAllowed(parentTag)) { + // If HTML children are allowed, only allow the element if is actually an HTML element or the root + // element of MathML/SVG. + if ("math".equals(element.getTagName())) { + result = new TagInformation(element.getTagName(), MATHML_NAMESPACE); + } else if ("svg".equals(element.getTagName())) { + result = new TagInformation(element.getTagName(), SVG_NAMESPACE); + } else if (isPossiblyHtmlTag(element.getTagName())) { + result = new TagInformation(element.getTagName(), HTML_NAMESPACE); + } + } + return result; + } + + /** + * @param tagName the tag name to check + * @param parentTag the parent information + * @return if the tag is an SVG tag and not also an HTML tag that is nested in an HTML integration point in SVG + */ + private boolean isPureSVGTag(String tagName, TagInformation parentTag) + { + return this.svgDefinitions.isSVGTag(tagName) && ( + !this.svgDefinitions.isHTMLIntegrationPoint(parentTag.tagName) + || !this.svgDefinitions.isCommonHTMLElement(tagName)); + } + + private boolean areHTMLChildrenAllowed(TagInformation parent) + { + boolean result = HTML_NAMESPACE.equals(parent.namespace); + result = result || (SVG_NAMESPACE.equals(parent.namespace) + && this.svgDefinitions.isHTMLIntegrationPoint(parent.tagName)); + result = result || (MATHML_NAMESPACE.equals(parent.namespace) + && this.mathMLDefinitions.isTextOrHTMLIntegrationPoint(parent.tagName)); + return result; + } + + /** + * @param tagName the tag name to check + * @return if the given tag is neither a MathML tag nor an SVG tag that is also an HTML tag + */ + private boolean isPossiblyHtmlTag(String tagName) + { + return !this.mathMLDefinitions.isMathMLTag(tagName) + && (!this.svgDefinitions.isSVGTag(tagName) || this.svgDefinitions.isCommonHTMLElement(tagName)); + } + + private List<Attr> getAttributes(Element element) + { + NamedNodeMap attributeNodes = element.getAttributes(); + List<Attr> result = new ArrayList<>(); + + for (int i = 0, length = attributeNodes.getLength(); i < length; ++i) { + result.add((Attr) attributeNodes.item(i)); + } + + return result; + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/resources/META-INF/components.txt+1 −0 modified@@ -6,6 +6,7 @@ org.xwiki.xml.internal.html.filter.BodyFilter org.xwiki.xml.internal.html.filter.ControlCharactersFilter org.xwiki.xml.internal.html.filter.AttributeFilter org.xwiki.xml.internal.html.filter.UniqueIdFilter +org.xwiki.xml.internal.html.filter.SanitizerFilter org.xwiki.xml.internal.html.DefaultHTMLCleaner org.xwiki.xml.internal.html.XWikiHTML5TagProvider org.xwiki.xml.internal.html.DefaultHTMLElementSanitizer
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/html/filter/AbstractHTMLFilterTest.java+16 −0 modified@@ -28,11 +28,18 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xwiki.component.manager.ComponentManager; +import org.xwiki.configuration.internal.RestrictedConfigurationSourceProvider; import org.xwiki.context.internal.DefaultExecution; import org.xwiki.test.annotation.ComponentList; import org.xwiki.test.junit5.mockito.ComponentTest; import org.xwiki.xml.html.HTMLCleaner; import org.xwiki.xml.internal.html.DefaultHTMLCleaner; +import org.xwiki.xml.internal.html.DefaultHTMLElementSanitizer; +import org.xwiki.xml.internal.html.HTMLDefinitions; +import org.xwiki.xml.internal.html.HTMLElementSanitizerConfiguration; +import org.xwiki.xml.internal.html.MathMLDefinitions; +import org.xwiki.xml.internal.html.SVGDefinitions; +import org.xwiki.xml.internal.html.SecureHTMLElementSanitizer; import org.xwiki.xml.internal.html.XWikiHTML5TagProvider; import org.xwiki.xml.internal.html.filter.AttributeFilter; import org.xwiki.xml.internal.html.filter.BodyFilter; @@ -41,6 +48,7 @@ import org.xwiki.xml.internal.html.filter.LinkFilter; import org.xwiki.xml.internal.html.filter.ListFilter; import org.xwiki.xml.internal.html.filter.ListItemFilter; +import org.xwiki.xml.internal.html.filter.SanitizerFilter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -63,6 +71,14 @@ DefaultHTMLCleaner.class, DefaultExecution.class, ControlCharactersFilter.class, + SanitizerFilter.class, + DefaultHTMLElementSanitizer.class, + SecureHTMLElementSanitizer.class, + HTMLElementSanitizerConfiguration.class, + RestrictedConfigurationSourceProvider.class, + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class, XWikiHTML5TagProvider.class }) // @formatter:on
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/html/HTMLUtilsTest.java+16 −0 modified@@ -25,11 +25,18 @@ import org.junit.jupiter.api.Test; import org.w3c.dom.Document; import org.xwiki.component.manager.ComponentManager; +import org.xwiki.configuration.internal.RestrictedConfigurationSourceProvider; import org.xwiki.context.internal.DefaultExecution; import org.xwiki.test.annotation.ComponentList; import org.xwiki.test.junit5.mockito.ComponentTest; import org.xwiki.xml.internal.html.DefaultHTMLCleaner; import org.xwiki.xml.internal.html.DefaultHTMLCleanerTest; +import org.xwiki.xml.internal.html.DefaultHTMLElementSanitizer; +import org.xwiki.xml.internal.html.HTMLDefinitions; +import org.xwiki.xml.internal.html.HTMLElementSanitizerConfiguration; +import org.xwiki.xml.internal.html.MathMLDefinitions; +import org.xwiki.xml.internal.html.SVGDefinitions; +import org.xwiki.xml.internal.html.SecureHTMLElementSanitizer; import org.xwiki.xml.internal.html.XWikiHTML5TagProvider; import org.xwiki.xml.internal.html.filter.AttributeFilter; import org.xwiki.xml.internal.html.filter.BodyFilter; @@ -38,6 +45,7 @@ import org.xwiki.xml.internal.html.filter.LinkFilter; import org.xwiki.xml.internal.html.filter.ListFilter; import org.xwiki.xml.internal.html.filter.ListItemFilter; +import org.xwiki.xml.internal.html.filter.SanitizerFilter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -58,6 +66,14 @@ BodyFilter.class, AttributeFilter.class, ControlCharactersFilter.class, + SanitizerFilter.class, + DefaultHTMLElementSanitizer.class, + SecureHTMLElementSanitizer.class, + HTMLElementSanitizerConfiguration.class, + RestrictedConfigurationSourceProvider.class, + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class, DefaultHTMLCleaner.class, DefaultExecution.class, XWikiHTML5TagProvider.class
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/internal/html/DefaultHTMLCleanerTest.java+58 −12 modified@@ -36,6 +36,7 @@ import org.w3c.dom.Document; import org.w3c.dom.NodeList; import org.xwiki.component.manager.ComponentManager; +import org.xwiki.configuration.internal.RestrictedConfigurationSourceProvider; import org.xwiki.test.annotation.ComponentList; import org.xwiki.test.junit5.mockito.ComponentTest; import org.xwiki.test.junit5.mockito.InjectMockComponents; @@ -49,6 +50,7 @@ import org.xwiki.xml.internal.html.filter.LinkFilter; import org.xwiki.xml.internal.html.filter.ListFilter; import org.xwiki.xml.internal.html.filter.ListItemFilter; +import org.xwiki.xml.internal.html.filter.SanitizerFilter; import org.xwiki.xml.internal.html.filter.UniqueIdFilter; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -71,6 +73,14 @@ DefaultHTMLCleaner.class, LinkFilter.class, ControlCharactersFilter.class, + SanitizerFilter.class, + DefaultHTMLElementSanitizer.class, + SecureHTMLElementSanitizer.class, + HTMLElementSanitizerConfiguration.class, + RestrictedConfigurationSourceProvider.class, + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class, XWikiHTML5TagProvider.class }) // @formatter:on @@ -107,7 +117,7 @@ public String getHeaderFull() /** * Cleans using the cleaner configuration {@link DefaultHTMLCleanerTest#cleanerConfiguration}. - * + * <p> * Ensures that always the correct configuration is used and allows executing the same tests for HTML 4 and HTML 5. * * @param originalHtmlContent The content to clean as string. @@ -322,6 +332,31 @@ void restrictedHtml() assertEquals(getHeaderFull() + "<pre>p {color:white;}</pre>" + FOOTER, result); } + /** + * Verify that the restricted parameter forbids dangerous attributes and tags. + */ + @Test + void restrictedAttributesAndTags() throws Exception + { + Map<String, String> parameters = new HashMap<>(this.cleanerConfiguration.getParameters()); + parameters.put("restricted", "true"); + this.cleanerConfiguration.setParameters(parameters); + + assertHTML("<p><img src=\"img.png\" /></p>", "<img onerror=\"alert(1)\" src=img.png />"); + assertHTML("<p><a>Hello!</a></p>", "<a href=\"javascript:alert(1)\">Hello!</a>"); + assertHTML("<p></p>", "<iframe src=\"whatever\"/>"); + + // Check that SVG is still working in restricted mode. + cleanSVGTags(); + cleanTitleWithNamespace(); + + // Check that MathML is still working in restricted mode. + assertHTML("<p><math xmlns=\"http://www.w3.org/1998/Math/MathML\"><mtext>X</mtext><mi><span>foo</span>" + + "</mi></math></p>", + "<math xmlns=\"http://www.w3.org/1998/Math/MathML\"><span></span><mtext>X</mtext><mi><span>foo</span>" + + "</mi></math>"); + } + /** * Verify that passing a fully-formed XHTML header works fine. */ @@ -366,30 +401,29 @@ void cleanSVGTags() throws Exception * also * <a href="https://jira.xwiki.org/browse/XWIKI-9753">XWIKI-9753</a>). */ - @Disabled("See https://jira.xwiki.org/browse/XWIKI-9753") @Test void cleanTitleWithNamespace() { // Test with TITLE in HEAD String input = - "<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\">\n" - + " <head>\n" + "<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\">" + + "<head>\n" + " <title>Title test</title>\n" - + " </head>\n" - + " <body>\n" + + " </head>" + + "<body>\n" + " <p>before</p>\n" - + " <svg xmlns=\"http://www.w3.org/2000/svg\" height=\"300\" width=\"500\">\n" + + " <p><svg xmlns=\"http://www.w3.org/2000/svg\" height=\"300\" width=\"500\">\n" + " <g>\n" + " <title>SVG Title Demo example</title>\n" + " <rect height=\"50\" style=\"fill:none; stroke:blue; stroke-width:1px\" width=\"200\" x=\"10\" " - + "y=\"10\"></rect>\n" + " </g>\n" + " </svg>\n" + " <p>after</p>\n"; + + "y=\"10\"></rect>\n" + " </g>\n" + " </svg></p>\n" + " <p>after</p>\n"; assertEquals(getHeader() + input + FOOTER, HTMLUtils.toString(clean(input))); } /** - * Verify that a xmlns namespace set on the HTML element is not removed by default and it's removed if {@link - * HTMLCleanerConfiguration#NAMESPACES_AWARE} is set to false. + * Verify that a xmlns namespace set on the HTML element is not removed by default and it's removed if + * {@link HTMLCleanerConfiguration#NAMESPACES_AWARE} is set to false. */ @Test void cleanHTMLTagWithNamespace() @@ -409,7 +443,19 @@ void cleanHTMLTagWithNamespace() } /** - * Test that cleaning an empty DIV works (it used to fail, see <a href="https://jira.xwiki.org/browse/XWIKI-4007">XWIKI-4007</a>). + * Check that template tags inside select don't survive, might be security-relevant, DOMPurify contains a similar + * check, see <a href="https://github.com/cure53/DOMPurify/commit/e32ca248c0e9450fb182e52e978631cbd78f1123">commit + * e32ca248c0 in DOMPurify</a>. + */ + @Test + void cleanTemplateInsideSelect() + { + assertHTML("<p><select></select></p>", "<select><template></template></select>"); + } + + /** + * Test that cleaning an empty DIV works (it used to fail, see <a + * href="https://jira.xwiki.org/browse/XWIKI-4007">XWIKI-4007</a>). */ @Test void cleanEmptyDIV() @@ -607,7 +653,7 @@ void divInsideDl() /** * Check what happens when the dt-tag is inside div. - * + * <p> * This should add a wrapping dl but doesn't for HTML 4, but it works in HTML5, see * {@link HTML5HTMLCleanerTest#divWithDt()}. *
4a185e0594d9XCOMMONS-2426: Provide a component for filtering safe HTML elements and attributes
14 files changed · +1484 −1
xwiki-commons-core/xwiki-commons-xml/pom.xml+6 −1 modified@@ -32,7 +32,7 @@ <packaging>jar</packaging> <description>XWiki Commons - XML</description> <properties> - <xwiki.jacoco.instructionRatio>0.72</xwiki.jacoco.instructionRatio> + <xwiki.jacoco.instructionRatio>0.82</xwiki.jacoco.instructionRatio> <!-- There's a utility class with lots of features, allow it to have many dependencies; There's a SAX event listener, which requires complex code --> <checkstyle.suppressions.location>${basedir}/src/main/checkstyle/checkstyle-suppressions.xml @@ -53,6 +53,11 @@ <artifactId>xwiki-commons-context</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.xwiki.commons</groupId> + <artifactId>xwiki-commons-configuration-api</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId>
xwiki-commons-core/xwiki-commons-xml/src/main/checkstyle/checkstyle-suppressions.xml+5 −0 modified@@ -36,4 +36,9 @@ <!-- XWikiDomSerializer copied from DomSerializer --> <suppress checks="CyclomaticComplexity" files="XWikiDOMSerializer" /> <suppress checks="NPathComplexity" files="XWikiDOMSerializer" /> + + <!-- These files have lists of strings copied from a source, making them constants would complicate updating from + upstream. --> + <suppress checks="MultipleStringLiterals" + files="SecureHTMLElementSanitizer.java|HTMLDefinitions.java|MathMLDefinitions.java|SVGDefinitions.java"/> </suppressions>
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/html/HTMLElementSanitizer.java+55 −0 added@@ -0,0 +1,55 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.html; + +import org.xwiki.component.annotation.Role; +import org.xwiki.stability.Unstable; + +/** + * Provides methods to check if HTML elements and attributes/attribute values are considered safe. + * <p> + * This also includes SVG and MathML elements and attributes. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Role +@Unstable +public interface HTMLElementSanitizer +{ + /** + * The key under which a hint can be stored that will be used by the default implementation. + */ + String EXECUTION_CONTEXT_HINT_KEY = "xml.html.htmlElementSanitizerHint"; + + /** + * @param elementName the name of the HTML element + * @return {@code true} if the given element is allowed in principle (given appropriate attributes) + */ + boolean isElementAllowed(String elementName); + + /** + * @param elementName the element for which the attributes shall be checked + * @param attributeName the attributes to check + * @param value the value of the attribute + * @return {@code true} if the attribute with this value is considered safe + */ + boolean isAttributeAllowed(String elementName, String attributeName, String value); +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/DefaultHTMLElementSanitizer.java+135 −0 added@@ -0,0 +1,135 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Singleton; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.xwiki.component.annotation.Component; +import org.xwiki.component.manager.ComponentLookupException; +import org.xwiki.component.manager.ComponentManager; +import org.xwiki.component.phase.Initializable; +import org.xwiki.component.phase.InitializationException; +import org.xwiki.configuration.ConfigurationSource; +import org.xwiki.context.Execution; +import org.xwiki.context.ExecutionContext; +import org.xwiki.stability.Unstable; +import org.xwiki.xml.html.HTMLElementSanitizer; + +/** + * Default {@link HTMLElementSanitizer} that loads the implementation chosen by the configuration. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component +@Singleton +@Unstable +public class DefaultHTMLElementSanitizer implements HTMLElementSanitizer, Initializable +{ + private static final String CONFIGURATION_KEY = "xml.htmlElementSanitizer"; + + private HTMLElementSanitizer implementation; + + @Inject + @Named("restricted") + private Provider<ConfigurationSource> configurationSourceProvider; + + @Inject + private Execution execution; + + @Inject + private Provider<ComponentManager> componentManagerProvider; + + @Inject + private Logger logger; + + @Override + public void initialize() throws InitializationException + { + + ConfigurationSource configurationSource = this.configurationSourceProvider.get(); + + String hint; + if (configurationSource != null) { + hint = configurationSource.getProperty(CONFIGURATION_KEY, SecureHTMLElementSanitizer.HINT); + } else { + hint = SecureHTMLElementSanitizer.HINT; + } + + try { + this.implementation = loadImplementationWithSecureFallback(hint); + } catch (ComponentLookupException ex) { + throw new InitializationException("Couldn't initialize the default secure HTMLElementSanitizer", ex); + } + } + + private HTMLElementSanitizer loadImplementationWithSecureFallback(String hint) throws ComponentLookupException + { + ComponentManager componentManager = this.componentManagerProvider.get(); + HTMLElementSanitizer result; + + try { + result = componentManager.getInstance(HTMLElementSanitizer.class, hint); + } catch (ComponentLookupException e) { + this.logger.error("Couldn't load the configured HTMLElementSanitizer with hint [{}], falling back to the " + + "default secure implementation: {}", hint, ExceptionUtils.getRootCauseMessage(e)); + result = componentManager.getInstance(HTMLElementSanitizer.class, SecureHTMLElementSanitizer.HINT); + } + + return result; + } + + private HTMLElementSanitizer getImplementation() + { + ExecutionContext context = this.execution.getContext(); + + HTMLElementSanitizer result = this.implementation; + + if (context != null && context.hasProperty(HTMLElementSanitizer.EXECUTION_CONTEXT_HINT_KEY)) { + String hint = (String) context.getProperty(HTMLElementSanitizer.EXECUTION_CONTEXT_HINT_KEY); + + try { + result = this.componentManagerProvider.get().getInstance(HTMLElementSanitizer.class, hint); + } catch (ComponentLookupException e) { + this.logger.error("Couldn't load the HTMLElementSanitizer with hint [{}] from the execution context, " + + "falling back to the configured implementation: {}", hint, ExceptionUtils.getRootCauseMessage(e)); + } + } + + return result; + } + + @Override + public boolean isElementAllowed(String elementName) + { + return getImplementation().isElementAllowed(elementName); + } + + @Override + public boolean isAttributeAllowed(String elementName, String attributeName, String value) + { + return getImplementation().isAttributeAllowed(elementName, attributeName, value); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/HTMLDefinitions.java+110 −0 added@@ -0,0 +1,110 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +/* + * Alternatively, at your choice, the contents of this file may be used under the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package org.xwiki.xml.internal.html; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.inject.Singleton; + +import org.xwiki.component.annotation.Component; + +/** + * Provides definitions of safe HTML attributes and tags. + * <p> + * Unless otherwise noted, lists of elements and attributes are copied from DOMPurify by Cure53 and other contributors | + * Released under the Apache license 2.0 and Mozilla Public License 2.0 - + * <a href="https://github.com/cure53/DOMPurify/blob/main/LICENSE">LICENSE</a>. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component(roles = HTMLDefinitions.class) +@Singleton +public class HTMLDefinitions +{ + /** + * Allowed HTML elements. + */ + private final Set<String> htmlTags; + + /** + * Allowed attributes. + */ + private final Set<String> htmlAttributes; + + /** + * Default constructor. + */ + public HTMLDefinitions() + { + this.htmlTags = new HashSet<>( + Arrays.asList("a", "abbr", "acronym", "address", "area", "article", "aside", "audio", "b", "bdi", "bdo", + "big", "blink", "blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", + "col", "colgroup", "content", "data", "datalist", "dd", "decorator", "del", "details", "dfn", "dialog", + "dir", "div", "dl", "dt", "element", "em", "fieldset", "figcaption", "figure", "font", "footer", "form", + "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html", "i", "img", "input", + "ins", "kbd", "label", "legend", "li", "main", "map", "mark", "marquee", "menu", "menuitem", "meter", + "nav", "nobr", "ol", "optgroup", "option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt", + "ruby", "s", "samp", "section", "select", "shadow", "small", "source", "spacer", "span", "strike", + "strong", "style", "sub", "summary", "sup", "table", "tbody", "td", "template", "textarea", "tfoot", + "th", "thead", "time", "tr", "track", "tt", "u", "ul", "var", "video", "wbr")); + + // Attributes that are in general allowed. Note that "target" is not generally safe, but XWiki contains code + // that already adds the necessary attributes to make it safe both in HTMLCleaner and in XHTML rendering. + this.htmlAttributes = new HashSet<>( + Arrays.asList("accept", "action", "align", "alt", "autocapitalize", "autocomplete", "autopictureinpicture", + "autoplay", "background", "bgcolor", "border", "capture", "cellpadding", "cellspacing", "checked", + "cite", "class", "clear", "color", "cols", "colspan", "controls", "controlslist", "coords", + "crossorigin", "datetime", "decoding", "default", "dir", "disabled", "disablepictureinpicture", + "disableremoteplayback", "download", "draggable", "enctype", "enterkeyhint", "face", "for", "headers", + "height", "hidden", "high", "href", "hreflang", "id", "inputmode", "integrity", "ismap", "kind", + "label", "lang", "list", "loading", "loop", "low", "max", "maxlength", "media", "method", "min", + "minlength", "multiple", "muted", "name", "nonce", "noshade", "novalidate", "nowrap", "open", "optimum", + "pattern", "placeholder", "playsinline", "poster", "preload", "pubdate", "radiogroup", "readonly", + "rel", "required", "rev", "reversed", "role", "rows", "rowspan", "spellcheck", "scope", "selected", + "shape", "size", "sizes", "span", "srclang", "start", "src", "srcset", "step", "style", "summary", + "tabindex", "title", "translate", "type", "usemap", "valign", "value", "width", "xmlns", "slot", + "target")); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is considered safe + */ + public boolean isSafeTag(String tagName) + { + return this.htmlTags.contains(tagName); + } + + /** + * @param attributeName the name of the attribute to check + * @return if the attribute is allowed + */ + public boolean isAllowedAttribute(String attributeName) + { + return this.htmlAttributes.contains(attributeName); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/HTMLElementSanitizerConfiguration.java+144 −0 added@@ -0,0 +1,144 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.configuration.ConfigurationSource; + +/** + * Provides methods to easily access the configuration options of {@link org.xwiki.xml.html.HTMLElementSanitizer}. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component(roles = HTMLElementSanitizerConfiguration.class) +@Singleton +public class HTMLElementSanitizerConfiguration +{ + private static final String EXTRA_ALLOWED_TAGS_CONFIGURATION = "xml.htmlElementSanitizer.extraAllowedTags"; + + private static final String EXTRA_ALLOWED_ATTRIBUTES_CONFIGURATION = + "xml.htmlElementSanitizer.extraAllowedAttributes"; + + private static final String EXTRA_URI_SAFE_ATTRIBUTES_CONFIGURATION = + "xml.htmlElementSanitizer.extraURISafeAttributes"; + + private static final String EXTRA_DATA_URI_TAGS_CONFIGURATION = "xml.htmlElementSanitizer.extraDataUriTags"; + + private static final String FORBID_TAGS_CONFIGURATION = "xml.htmlElementSanitizer.forbidTags"; + + private static final String FORBID_ATTRIBUTES_CONFIGURATION = "xml.htmlElementSanitizer.forbidAttributes"; + + private static final String ALLOW_UNKNOWN_PROTOCOLS_CONFIGURATION = + "xml.htmlElementSanitizer.allowUnknownProtocols"; + + private static final String ALLOWED_URI_REGEXP_CONFIGURATION = "xml.htmlElementSanitizer.allowedUriRegexp"; + + @Inject + @Named("restricted") + private Provider<ConfigurationSource> configurationSourceProvider; + + private <T> T getValue(String key, Class<T> valueType, T defaultValue) + { + ConfigurationSource configurationSource = this.configurationSourceProvider.get(); + + T result; + + if (configurationSource != null) { + result = configurationSource.getProperty(key, valueType, defaultValue); + } else { + result = defaultValue; + } + + return result; + } + + /** + * @return The list of additionally allowed tags + */ + public List<String> getExtraAllowedTags() + { + return getValue(EXTRA_ALLOWED_TAGS_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return the list of additionally allowed attributes + */ + public List<String> getExtraAllowedAttributes() + { + return getValue(EXTRA_ALLOWED_ATTRIBUTES_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return the list of additional tags that are safe for all kinds of URIs + */ + public List<String> getExtraUriSafeAttributes() + { + return getValue(EXTRA_URI_SAFE_ATTRIBUTES_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return the list of additional tags whose attributes may have data URIs + */ + public List<String> getExtraDataUriTags() + { + return getValue(EXTRA_DATA_URI_TAGS_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return the list of forbidden tags + */ + public List<String> getForbidTags() + { + return getValue(FORBID_TAGS_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return the list of forbidden attributes + */ + public List<String> getForbidAttributes() + { + return getValue(FORBID_ATTRIBUTES_CONFIGURATION, List.class, Collections.emptyList()); + } + + /** + * @return if unknown protocols shall be allowed + */ + public boolean isAllowUnknownProtocols() + { + return getValue(ALLOW_UNKNOWN_PROTOCOLS_CONFIGURATION, Boolean.class, Boolean.TRUE); + } + + /** + * @return the regular expression for allowed URIs + */ + public String getAllowedUriRegexp() + { + return getValue(ALLOWED_URI_REGEXP_CONFIGURATION, String.class, null); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/InsecureHTMLElementSanitizer.java+52 −0 added@@ -0,0 +1,52 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import javax.inject.Named; +import javax.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.stability.Unstable; +import org.xwiki.xml.html.HTMLElementSanitizer; + +/** + * Implementation of {@link HTMLElementSanitizer} that allows all elements and attributes. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component +@Singleton +@Named("insecure") +@Unstable +public class InsecureHTMLElementSanitizer implements HTMLElementSanitizer +{ + @Override + public boolean isElementAllowed(String elementName) + { + return true; + } + + @Override + public boolean isAttributeAllowed(String elementName, String attributeName, String value) + { + return true; + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/MathMLDefinitions.java+119 −0 added@@ -0,0 +1,119 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +/* + * Alternatively, at your choice, the contents of this file may be used under the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package org.xwiki.xml.internal.html; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.inject.Singleton; + +import org.xwiki.component.annotation.Component; + +/** + * Provides MathML tag and attribute definitions with a focus on safe tags/attributes. + * <p> + * Unless otherwise noted, lists of elements and attributes are copied from DOMPurify by Cure53 and other contributors | + * Released under the Apache license 2.0 and Mozilla Public License 2.0 - + * <a href="https://github.com/cure53/DOMPurify/blob/main/LICENSE">LICENSE</a>. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component(roles = MathMLDefinitions.class) +@Singleton +public class MathMLDefinitions +{ + private final Set<String> safeTags; + + private final Set<String> allTags; + + private final Set<String> allowedAttributes; + + private final Set<String> textIntegrationPoints; + + /** + * Default constructor. + */ + public MathMLDefinitions() + { + this.safeTags = new HashSet<>( + Arrays.asList("math", "menclose", "merror", "mfenced", "mfrac", "mglyph", "mi", "mlabeledtr", + "mmultiscripts", "mn", "mo", "mover", "mpadded", "mphantom", "mroot", "mrow", "ms", "mspace", "msqrt", + "mstyle", "msub", "msup", "msubsup", "mtable", "mtd", "mtext", "mtr", "munder", "munderover")); + + this.allTags = new HashSet<>( + Arrays.asList("maction", "maligngroup", "malignmark", "mlongdiv", "mscarries", "mscarry", "msgroup", + "mstack", "msline", "msrow", "semantics", "annotation", "annotation-xml", "mprescripts", "none")); + + this.allTags.addAll(this.safeTags); + + this.allowedAttributes = new HashSet<>( + Arrays.asList("accent", "accentunder", "align", "bevelled", "close", "columnsalign", "columnlines", + "columnspan", "denomalign", "depth", "dir", "display", "displaystyle", "encoding", "fence", "frame", + "height", "href", "id", "largeop", "length", "linethickness", "lspace", "lquote", "mathbackground", + "mathcolor", "mathsize", "mathvariant", "maxsize", "minsize", "movablelimits", "notation", "numalign", + "open", "rowalign", "rowlines", "rowspacing", "rowspan", "rspace", "rquote", "scriptlevel", + "scriptminsize", "scriptsizemultiplier", "selection", "separator", "separators", "stretchy", + "subscriptshift", "supscriptshift", "symmetric", "voffset", "width", "xmlns")); + + this.textIntegrationPoints = new HashSet<>(Arrays.asList("mi", "mo", "mn", "ms", "mtext", "annotation-xml")); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is considered safe + */ + public boolean isSafeTag(String tagName) + { + return this.safeTags.contains(tagName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is a MathML tag + */ + public boolean isMathMLTag(String tagName) + { + return this.allTags.contains(tagName); + } + + /** + * @param attributeName the name of the attribute to check + * @return if the attribute is allowed + */ + public boolean isAllowedAttribute(String attributeName) + { + return this.allowedAttributes.contains(attributeName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is a text integration point, i.e., its children can be HTML elements + */ + public boolean isTextOrHTMLIntegrationPoint(String tagName) + { + return this.textIntegrationPoints.contains(tagName); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/SecureHTMLElementSanitizer.java+233 −0 added@@ -0,0 +1,233 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +/* + * Alternatively, at your choice, the contents of this file may be used under the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package org.xwiki.xml.internal.html; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; +import org.xwiki.component.annotation.Component; +import org.xwiki.component.phase.Initializable; +import org.xwiki.component.phase.InitializationException; +import org.xwiki.stability.Unstable; +import org.xwiki.xml.html.HTMLElementSanitizer; + +/** + * Secure default implementation of {@link HTMLElementSanitizer} based on a definition of allowed elements and + * attributes. + * <p> + * This is heavily inspired by DOMPurify by Cure53 and other contributors | Released under the Apache license 2.0 and + * Mozilla Public License 2.0 - <a href="https://github.com/cure53/DOMPurify/blob/main/LICENSE">LICENSE</a>. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component +@Named(SecureHTMLElementSanitizer.HINT) +@Singleton +@Unstable +public class SecureHTMLElementSanitizer implements HTMLElementSanitizer, Initializable +{ + /** + * The hint of this component. + */ + public static final String HINT = "secure"; + + static final Pattern IS_SCRIPT_OR_DATA = Pattern.compile("^(?:\\w+script|data):", Pattern.CASE_INSENSITIVE); + + static final Pattern ATTR_WHITESPACE = + Pattern.compile("[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]"); + + static final Pattern DATA_ATTR = Pattern.compile("^data-[\\-\\w.\\u00B7-\\uFFFF]"); + + static final Pattern ARIA_ATTR = Pattern.compile("^aria-[\\-\\w]+$"); + + static final Pattern IS_ALLOWED_URI = Pattern.compile("^(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):", + Pattern.CASE_INSENSITIVE); + + static final Pattern IS_NO_URI = Pattern.compile("^(?:[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))", + Pattern.CASE_INSENSITIVE); + + @Inject + private HTMLElementSanitizerConfiguration htmlElementSanitizerConfiguration; + + @Inject + private HTMLDefinitions htmlDefinitions; + + @Inject + private SVGDefinitions svgDefinitions; + + @Inject + private MathMLDefinitions mathMLDefinitions; + + /** + * Additionally allowed elements. + */ + private final Set<String> extraAllowedTags; + + /** + * Additionally allowed attributes. + */ + private final Set<String> extraAllowedAttributes; + + /** + * XML attributes that should be allowed. + */ + private final Set<String> xmlAttributes; + + /** + * Tags that are safe for data: URIs. + */ + private final Set<String> dataUriTags; + + /** + * Attributes safe for values like "javascript:". + */ + private final Set<String> uriSafeAttributes; + + private final Set<String> forbidTags; + + private final Set<String> forbidAttributes; + + private boolean allowUnknownProtocols; + + private Pattern allowedUriPattern; + + /** + * Default constructor. + */ + public SecureHTMLElementSanitizer() + { + this.dataUriTags = new HashSet<>(Arrays.asList("audio", "video", "img", "source", "image", "track")); + + this.uriSafeAttributes = new HashSet<>( + Arrays.asList("alt", "class", "for", "id", "label", "name", "pattern", "placeholder", "role", "summary", + "title", "value", "style", "xmlns")); + + this.xmlAttributes = + new HashSet<>(Arrays.asList("xlink:href", "xml:id", "xlink:title", "xml:space", "xmlns:xlink")); + + this.extraAllowedTags = new HashSet<>(); + + this.extraAllowedAttributes = new HashSet<>(); + + this.forbidTags = new HashSet<>(); + + this.forbidAttributes = new HashSet<>(); + + this.allowedUriPattern = IS_ALLOWED_URI; + } + + @Override + public void initialize() throws InitializationException + { + this.extraAllowedTags.addAll(this.htmlElementSanitizerConfiguration.getExtraAllowedTags()); + this.extraAllowedAttributes.addAll(this.htmlElementSanitizerConfiguration.getExtraAllowedAttributes()); + this.uriSafeAttributes.addAll(this.htmlElementSanitizerConfiguration.getExtraUriSafeAttributes()); + this.dataUriTags.addAll(this.htmlElementSanitizerConfiguration.getExtraDataUriTags()); + this.allowUnknownProtocols = this.htmlElementSanitizerConfiguration.isAllowUnknownProtocols(); + this.forbidTags.addAll(this.htmlElementSanitizerConfiguration.getForbidTags()); + this.forbidAttributes.addAll(this.htmlElementSanitizerConfiguration.getForbidAttributes()); + String configuredRegexp = this.htmlElementSanitizerConfiguration.getAllowedUriRegexp(); + if (StringUtils.isNotBlank(configuredRegexp)) { + this.allowedUriPattern = Pattern.compile(configuredRegexp, Pattern.CASE_INSENSITIVE); + } + } + + @Override + public boolean isElementAllowed(String elementName) + { + return !this.forbidTags.contains(elementName) + && (this.extraAllowedTags.contains(elementName) || isElementSafe(elementName)); + } + + private boolean isElementSafe(String elementName) + { + return this.htmlDefinitions.isSafeTag(elementName) || this.svgDefinitions.isSafeTag(elementName) + || this.mathMLDefinitions.isSafeTag(elementName); + } + + @Override + public boolean isAttributeAllowed(String elementName, String attributeName, String attributeValue) + { + boolean result = false; + + String lowerElement = elementName.toLowerCase(); + String lowerAttribute = attributeName.toLowerCase(); + + if ((DATA_ATTR.matcher(lowerAttribute).find() || ARIA_ATTR.matcher(lowerAttribute).find()) + && !this.forbidAttributes.contains(lowerAttribute)) + { + result = true; + } else if (isAttributeAllowed(lowerAttribute) && !this.forbidAttributes.contains(lowerAttribute)) { + result = isAllowedValue(lowerElement, lowerAttribute, attributeValue); + } + + return result; + } + + private boolean isAllowedValue(String lowercaseElementName, String lowercaseAttributeName, String attributeValue) + { + // Break into several statements to avoid too long boolean expression. + boolean result = StringUtils.isBlank(attributeValue); + if (!result) { + String valueNoWhitespace = ATTR_WHITESPACE.matcher(attributeValue).replaceAll(""); + result = this.uriSafeAttributes.contains(lowercaseAttributeName); + result = result || IS_NO_URI.matcher(valueNoWhitespace).find(); + result = result || this.allowedUriPattern.matcher(valueNoWhitespace).find(); + result = result || isAllowedDataValue(lowercaseElementName, lowercaseAttributeName, attributeValue); + result = result || (this.allowUnknownProtocols && !isScriptOrData(attributeValue)); + } + return result; + } + + private boolean isAttributeAllowed(String attributeName) + { + boolean result = this.extraAllowedAttributes.contains(attributeName); + result = result || this.htmlDefinitions.isAllowedAttribute(attributeName); + result = result || this.svgDefinitions.isAllowedAttribute(attributeName); + result = result || this.mathMLDefinitions.isAllowedAttribute(attributeName); + result = result || this.xmlAttributes.contains(attributeName); + return result; + } + + private boolean isScriptOrData(String attributeValue) + { + return IS_SCRIPT_OR_DATA.matcher(ATTR_WHITESPACE.matcher(attributeValue).replaceAll("")).find(); + } + + private boolean isAllowedDataValue(String elementName, String attributeName, String attributeValue) + { + boolean attributeAllowsData = "src".equals(attributeName) || "xlink:href".equals(attributeName) + || "href".equals(attributeName); + return attributeAllowsData && !"script".equals(elementName) && attributeValue.startsWith("data:") + && this.dataUriTags.contains(elementName); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/java/org/xwiki/xml/internal/html/SVGDefinitions.java+171 −0 added@@ -0,0 +1,171 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +/* + * Alternatively, at your choice, the contents of this file may be used under the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package org.xwiki.xml.internal.html; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.inject.Singleton; + +import org.xwiki.component.annotation.Component; + +/** + * Provides SVG tag and attribute definitions with a focus on safe tags/attributes. + * <p> + * Unless otherwise noted, lists of elements and attributes are copied from DOMPurify by Cure53 and other contributors | + * Released under the Apache license 2.0 and Mozilla Public License 2.0 - + * <a href="https://github.com/cure53/DOMPurify/blob/main/LICENSE">LICENSE</a>. + * + * @version $Id$ + * @since 14.6RC1 + */ +@Component(roles = SVGDefinitions.class) +@Singleton +public class SVGDefinitions +{ + private final Set<String> safeTags; + + private final Set<String> filterTags; + + private final Set<String> allTags; + + private final Set<String> allowedAttributes; + + private final Set<String> commonHTMLElements; + + private final Set<String> htmlIntegrationPoints; + + /** + * Default constructor. + */ + public SVGDefinitions() + { + this.allowedAttributes = new HashSet<>( + Arrays.asList("accent-height", "accumulate", "additive", "alignment-baseline", "ascent", "attributename", + "attributetype", "azimuth", "basefrequency", "baseline-shift", "begin", "bias", "by", "class", "clip", + "clippathunits", "clip-path", "clip-rule", "color", "color-interpolation", + "color-interpolation-filters", "color-profile", "color-rendering", "cx", "cy", "d", "dx", "dy", + "diffuseconstant", "direction", "display", "divisor", "dur", "edgemode", "elevation", "end", "fill", + "fill-opacity", "fill-rule", "filter", "filterunits", "flood-color", "flood-opacity", "font-family", + "font-size", "font-size-adjust", "font-stretch", "font-style", "font-variant", "font-weight", "fx", + "fy", "g1", "g2", "glyph-name", "glyphref", "gradientunits", "gradienttransform", "height", "href", + "id", "image-rendering", "in", "in2", "k", "k1", "k2", "k3", "k4", "kerning", "keypoints", "keysplines", + "keytimes", "lang", "lengthadjust", "letter-spacing", "kernelmatrix", "kernelunitlength", + "lighting-color", "local", "marker-end", "marker-mid", "marker-start", "markerheight", "markerunits", + "markerwidth", "maskcontentunits", "maskunits", "max", "mask", "media", "method", "mode", "min", "name", + "numoctaves", "offset", "operator", "opacity", "order", "orient", "orientation", "origin", "overflow", + "paint-order", "path", "pathlength", "patterncontentunits", "patterntransform", "patternunits", + "points", "preservealpha", "preserveaspectratio", "primitiveunits", "r", "rx", "ry", "radius", "refx", + "refy", "repeatcount", "repeatdur", "restart", "result", "rotate", "scale", "seed", "shape-rendering", + "specularconstant", "specularexponent", "spreadmethod", "startoffset", "stddeviation", "stitchtiles", + "stop-color", "stop-opacity", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", + "stroke-linejoin", "stroke-miterlimit", "stroke-opacity", "stroke", "stroke-width", "style", + "surfacescale", "systemlanguage", "tabindex", "targetx", "targety", "transform", "transform-origin", + "text-anchor", "text-decoration", "text-rendering", "textlength", "type", "u1", "u2", "unicode", + "values", "viewbox", "visibility", "version", "vert-adv-y", "vert-origin-x", "vert-origin-y", "width", + "word-spacing", "wrap", "writing-mode", "xchannelselector", "ychannelselector", "x", "x1", "x2", + "xmlns", "y", "y1", "y2", "z", "zoomandpan")); + + this.safeTags = new HashSet<>( + Arrays.asList("svg", "a", "altglyph", "altglyphdef", "altglyphitem", "animatecolor", "animatemotion", + "animatetransform", "circle", "clippath", "defs", "desc", "ellipse", "filter", "font", "g", "glyph", + "glyphref", "hkern", "image", "line", "lineargradient", "marker", "mask", "metadata", "mpath", "path", + "pattern", "polygon", "polyline", "radialgradient", "rect", "stop", "style", "switch", "symbol", "text", + "textpath", "title", "tref", "tspan", "view", "vkern")); + + this.filterTags = new HashSet<>( + Arrays.asList("feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix", + "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feFlood", "feFuncA", "feFuncB", "feFuncG", + "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset", + "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence")); + + this.allTags = new HashSet<>( + Arrays.asList("animate", "color-profile", "cursor", "discard", "fedropshadow", "font-face", + "font-face-format", "font-face-name", "font-face-src", "font-face-uri", "foreignobject", "hatch", + "hatchpath", "mesh", "meshgradient", "meshpatch", "meshrow", "missing-glyph", "script", "set", + "solidcolor", "unknown", "use")); + + this.allTags.addAll(this.filterTags); + this.allTags.addAll(this.safeTags); + + this.commonHTMLElements = new HashSet<>(Arrays.asList("title", "style", "font", "a", "script")); + + this.htmlIntegrationPoints = new HashSet<>(Arrays.asList("foreignobject", "desc", "title", "annotation-xml")); + } + + /** + * @param attributeName the attribute to check + * @return if the attribute is allowed, i.e., considered safe + */ + public boolean isAllowedAttribute(String attributeName) + { + return this.allowedAttributes.contains(attributeName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is considered safe + */ + public boolean isSafeTag(String tagName) + { + return this.safeTags.contains(tagName) || isFilterTag(tagName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is an SVG filter + */ + public boolean isFilterTag(String tagName) + { + return this.filterTags.contains(tagName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is an SVG tag + */ + public boolean isSVGTag(String tagName) + { + return this.allTags.contains(tagName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag is both an HTML and an SVG tag + */ + public boolean isCommonHTMLElement(String tagName) + { + return this.commonHTMLElements.contains(tagName); + } + + /** + * @param tagName the name of the tag to check + * @return if the tag can contain HTML children + */ + public boolean isHTMLIntegrationPoint(String tagName) + { + return this.htmlIntegrationPoints.contains(tagName); + } +}
xwiki-commons-core/xwiki-commons-xml/src/main/resources/META-INF/components.txt+7 −0 modified@@ -8,5 +8,12 @@ org.xwiki.xml.internal.html.filter.AttributeFilter org.xwiki.xml.internal.html.filter.UniqueIdFilter org.xwiki.xml.internal.html.DefaultHTMLCleaner org.xwiki.xml.internal.html.XWikiHTML5TagProvider +org.xwiki.xml.internal.html.DefaultHTMLElementSanitizer +org.xwiki.xml.internal.html.SecureHTMLElementSanitizer +org.xwiki.xml.internal.html.InsecureHTMLElementSanitizer +org.xwiki.xml.internal.html.HTMLElementSanitizerConfiguration +org.xwiki.xml.internal.html.HTMLDefinitions +org.xwiki.xml.internal.html.MathMLDefinitions +org.xwiki.xml.internal.html.SVGDefinitions org.xwiki.xml.internal.LocalEntityResolver org.xwiki.xml.internal.DefaultXMLReaderFactory
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/internal/html/DefaultHTMLElementSanitizerTest.java+183 −0 added@@ -0,0 +1,183 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.List; + +import javax.inject.Named; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.xwiki.component.manager.ComponentLookupException; +import org.xwiki.configuration.ConfigurationSource; +import org.xwiki.configuration.internal.RestrictedConfigurationSourceProvider; +import org.xwiki.context.Execution; +import org.xwiki.context.ExecutionContext; +import org.xwiki.test.LogLevel; +import org.xwiki.test.annotation.ComponentList; +import org.xwiki.test.junit5.LogCaptureExtension; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.MockComponent; +import org.xwiki.test.mockito.MockitoComponentManager; +import org.xwiki.xml.html.HTMLConstants; +import org.xwiki.xml.html.HTMLElementSanitizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Test the {@link DefaultHTMLElementSanitizer}. + * + * @version $Id$ + * @since 14.6RC1 + */ +@ComponentTest +@ComponentList({ DefaultHTMLElementSanitizer.class, + SecureHTMLElementSanitizer.class, + InsecureHTMLElementSanitizer.class, + HTMLElementSanitizerConfiguration.class, + RestrictedConfigurationSourceProvider.class, + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class +}) +class DefaultHTMLElementSanitizerTest +{ + private static final String EXPECTED_ERROR_LOADING_FOO = + "Couldn't load the configured HTMLElementSanitizer with hint [foo], falling back to " + + "the default secure implementation: ComponentLookupException: Can't find descriptor for the " + + "component with type [interface org.xwiki.xml.html.HTMLElementSanitizer] and hint [foo]"; + + private static final String EXPECTED_ERROR_LOADING_FOO_FROM_EXECUTION = "Couldn't load the HTMLElementSanitizer " + + "with hint [foo] from the execution context, falling back to the configured implementation: " + + "ComponentLookupException: Can't find descriptor for the component with type " + + "[interface org.xwiki.xml.html.HTMLElementSanitizer] and hint [foo]"; + + private static final String FOO = "foo"; + + private static final String INSECURE = "insecure"; + + @RegisterExtension + private final LogCaptureExtension logCaptureExtension = new LogCaptureExtension(LogLevel.ERROR); + + @MockComponent + @Named("restricted") + private ConfigurationSource configurationSource; + + @MockComponent + private Execution execution; + + @BeforeEach + void mockConfiguration() + { + when(this.configurationSource.getProperty(any(), eq(List.class), eq(Collections.emptyList()))) + .thenReturn(Collections.emptyList()); + when(this.configurationSource.getProperty(any(), eq(Boolean.class), eq(true))).thenReturn(true); + } + + @Test + void secure(MockitoComponentManager componentManager) throws ComponentLookupException + { + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))) + .thenReturn(SecureHTMLElementSanitizer.HINT); + HTMLElementSanitizer htmlElementSanitizer = componentManager.getInstance(HTMLElementSanitizer.class); + assertFalse(htmlElementSanitizer.isElementAllowed("no-such-element")); + assertFalse(htmlElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, "onerror", "hello")); + assertTrue(htmlElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_SPAN, "data-xwiki", "true")); + } + + @Test + void insecure(MockitoComponentManager componentManager) throws ComponentLookupException + { + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))) + .thenReturn(INSECURE); + HTMLElementSanitizer htmlElementSanitizer = componentManager.getInstance(HTMLElementSanitizer.class); + assertTrue(htmlElementSanitizer.isElementAllowed(HTMLConstants.TAG_SCRIPT)); + } + + @Test + void fallback(MockitoComponentManager componentManager) throws ComponentLookupException + { + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))).thenReturn(FOO); + + HTMLElementSanitizer htmlElementSanitizer = componentManager.getInstance(HTMLElementSanitizer.class); + + assertEquals(EXPECTED_ERROR_LOADING_FOO, this.logCaptureExtension.getMessage(0)); + assertFalse(htmlElementSanitizer.isElementAllowed(FOO)); + } + + @Test + void throwingWhenFailure(MockitoComponentManager componentManager) + { + componentManager.unregisterComponent((Type) HTMLElementSanitizer.class, SecureHTMLElementSanitizer.HINT); + + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))).thenReturn(FOO); + + ComponentLookupException exception = assertThrows(ComponentLookupException.class, + () -> componentManager.getInstance(HTMLElementSanitizer.class)); + assertEquals("Couldn't initialize the default secure HTMLElementSanitizer", + exception.getCause().getMessage()); + + assertEquals(EXPECTED_ERROR_LOADING_FOO, this.logCaptureExtension.getMessage(0)); + } + + @Test + void customFromExecutionContext(MockitoComponentManager componentManager) throws ComponentLookupException + { + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))) + .thenReturn(SecureHTMLElementSanitizer.HINT); + + HTMLElementSanitizer htmlElementSanitizer = componentManager.getInstance(HTMLElementSanitizer.class); + assertFalse(htmlElementSanitizer.isElementAllowed(FOO)); + + ExecutionContext context = new ExecutionContext(); + context.setProperty(HTMLElementSanitizer.EXECUTION_CONTEXT_HINT_KEY, INSECURE); + when(this.execution.getContext()).thenReturn(context); + + assertTrue(htmlElementSanitizer.isElementAllowed(FOO)); + } + + @Test + void fallBackToConfiguredWhenExecutionContextHintIsInvalid(MockitoComponentManager componentManager) + throws ComponentLookupException + { + when(this.configurationSource.getProperty(any(), eq(SecureHTMLElementSanitizer.HINT))) + .thenReturn(SecureHTMLElementSanitizer.HINT); + + HTMLElementSanitizer htmlElementSanitizer = componentManager.getInstance(HTMLElementSanitizer.class); + assertFalse(htmlElementSanitizer.isElementAllowed(FOO)); + + ExecutionContext context = new ExecutionContext(); + context.setProperty(HTMLElementSanitizer.EXECUTION_CONTEXT_HINT_KEY, FOO); + when(this.execution.getContext()).thenReturn(context); + + assertFalse(htmlElementSanitizer.isElementAllowed(FOO)); + + assertEquals(EXPECTED_ERROR_LOADING_FOO_FROM_EXECUTION, this.logCaptureExtension.getMessage(0)); + } +}
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/internal/html/HTMLElementSanitizerTest.java+128 −0 added@@ -0,0 +1,128 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.xwiki.test.annotation.ComponentList; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.xml.html.HTMLConstants; +import org.xwiki.xml.html.HTMLElementSanitizer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Test the {@link SecureHTMLElementSanitizer}. + * + * @version $Id$ + * @since 14.6RC1 + */ +@ComponentTest +@ComponentList({ + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class +}) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class HTMLElementSanitizerTest +{ + private static final String JAVASCRIPT_ALERT = "javascript:alert(1)"; + + @InjectMockComponents + private SecureHTMLElementSanitizer secureHTMLElementSanitizer; + + @InjectMockComponents + private InsecureHTMLElementSanitizer insecureHTMLElementSanitizer; + + @Test + void scriptIsDisallowedInSecureSanitizer() + { + assertFalse(this.secureHTMLElementSanitizer.isElementAllowed(HTMLConstants.TAG_SCRIPT)); + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, "onerror", "alert(1)")); + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_A, + HTMLConstants.ATTRIBUTE_HREF, JAVASCRIPT_ALERT)); + } + + @ParameterizedTest + @MethodSource("allSanitizersProvider") + void dataAttributeIsAllowed(Supplier<HTMLElementSanitizer> htmlElementSanitizer) + { + assertTrue(htmlElementSanitizer.get().isAttributeAllowed(HTMLConstants.TAG_SPAN, "data-foo", JAVASCRIPT_ALERT)); + } + + @ParameterizedTest + @MethodSource("allSanitizersProvider") + void dataImgIsAllowed(Supplier<HTMLElementSanitizer> htmlElementSanitizer) + { + assertTrue(htmlElementSanitizer.get().isAttributeAllowed(HTMLConstants.TAG_IMG, HTMLConstants.ATTRIBUTE_SRC, + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4" + + " //8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")); + } + + @ParameterizedTest + @MethodSource("allSanitizersProvider") + void linkProtocolsAreAllowed(Supplier<HTMLElementSanitizer> htmlElementSanitizer) + { + assertTrue(htmlElementSanitizer.get().isAttributeAllowed(HTMLConstants.TAG_A, HTMLConstants.ATTRIBUTE_HREF, + "https://www.xwiki.org")); + assertTrue(htmlElementSanitizer.get().isAttributeAllowed(HTMLConstants.TAG_A, HTMLConstants.ATTRIBUTE_HREF, + "tel:+1234567890")); + } + + @Test + void arbitraryAttributesAreDisallowedInSecureSanitizer() + { + assertFalse(this.secureHTMLElementSanitizer.isElementAllowed("foo")); + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_SPAN, "bar", "baz")); + } + + @Test + void arbitraryAttributesAreAllowedInInsecureSanitizer() + { + assertTrue(this.insecureHTMLElementSanitizer.isElementAllowed("xwiki")); + assertTrue(this.insecureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_SPAN, "hello", "world")); + } + + private Arguments getSecureSanitizer() + { + // Use a supplier because the components haven't been injected yet when the arguments are collected. + return arguments(Named.<Supplier<HTMLElementSanitizer>>of("secure", () -> this.secureHTMLElementSanitizer)); + } + + private Arguments getInsecureSanitizer() + { + return arguments(Named.<Supplier<HTMLElementSanitizer>>of("insecure", () -> this.insecureHTMLElementSanitizer)); + } + + Stream<Arguments> allSanitizersProvider() + { + return Stream.of(getSecureSanitizer(), getInsecureSanitizer()); + } +}
xwiki-commons-core/xwiki-commons-xml/src/test/java/org/xwiki/xml/internal/html/SecureHTMLElementSanitizerTest.java+136 −0 added@@ -0,0 +1,136 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.xml.internal.html; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.xwiki.test.annotation.BeforeComponent; +import org.xwiki.test.annotation.ComponentList; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; +import org.xwiki.xml.html.HTMLConstants; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the {@link SecureHTMLElementSanitizer}. + * + * @version $Id$ + * @since 14.6RC1 + */ +@ComponentTest +@ComponentList({ + HTMLDefinitions.class, + MathMLDefinitions.class, + SVGDefinitions.class +}) +class SecureHTMLElementSanitizerTest +{ + private static final String ALLOWED_ATTRIBUTE = "allowed_attribute"; + + private static final String ONERROR = "onerror"; + + @MockComponent + private HTMLElementSanitizerConfiguration htmlElementSanitizerConfiguration; + + @InjectMockComponents + private SecureHTMLElementSanitizer secureHTMLElementSanitizer; + + @BeforeComponent + void setupMocks() + { + when(this.htmlElementSanitizerConfiguration.getForbidTags()) + .thenReturn(Collections.singletonList(HTMLConstants.TAG_A)); + when(this.htmlElementSanitizerConfiguration.getForbidAttributes()) + .thenReturn(Collections.singletonList(HTMLConstants.ATTRIBUTE_ALT)); + when(this.htmlElementSanitizerConfiguration.getExtraAllowedTags()) + .thenReturn(Collections.singletonList(HTMLConstants.TAG_SCRIPT)); + when(this.htmlElementSanitizerConfiguration.getExtraAllowedAttributes()) + .thenReturn(Arrays.asList(ALLOWED_ATTRIBUTE, ONERROR)); + when(this.htmlElementSanitizerConfiguration.getExtraUriSafeAttributes()) + .thenReturn(Collections.singletonList(HTMLConstants.ATTRIBUTE_SRC)); + when(this.htmlElementSanitizerConfiguration.getExtraDataUriTags()) + .thenReturn(Arrays.asList(HTMLConstants.TAG_SCRIPT, HTMLConstants.TAG_NAV)); + when(this.htmlElementSanitizerConfiguration.isAllowUnknownProtocols()) + .thenReturn(false); + when(this.htmlElementSanitizerConfiguration.getAllowedUriRegexp()) + .thenReturn("^(xwiki|https):"); + } + + @Test + void forbiddenTags() + { + assertFalse(this.secureHTMLElementSanitizer.isElementAllowed(HTMLConstants.TAG_A)); + } + + @Test + void forbiddenAttributes() + { + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, + HTMLConstants.ATTRIBUTE_ALT, "XWiki")); + } + + @Test + void extraAllowedTags() + { + assertTrue(this.secureHTMLElementSanitizer.isElementAllowed(HTMLConstants.TAG_SCRIPT)); + } + + @Test + void extraAllowedAttributes() + { + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, ALLOWED_ATTRIBUTE, + "value")); + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, ONERROR, "alert(1)")); + } + + @Test + void extraUriSafeAttributes() + { + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_IMG, + HTMLConstants.ATTRIBUTE_SRC, "javascript:alert(1)")); + } + + @Test + void extraDataUriTags() + { + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_NAV, + HTMLConstants.ATTRIBUTE_HREF, "data:test")); + // Script cannot be enabled for data-attributes. + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_SCRIPT, + HTMLConstants.ATTRIBUTE_HREF, "data:script")); + } + + @Test + void restrictedURIs() + { + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_A, + HTMLConstants.ATTRIBUTE_HREF, "https://www.xwiki.org")); + assertTrue(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_A, + HTMLConstants.ATTRIBUTE_HREF, "xwiki:test")); + assertFalse(this.secureHTMLElementSanitizer.isAttributeAllowed(HTMLConstants.TAG_A, + HTMLConstants.ATTRIBUTE_HREF, "http://example.com")); + } +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-m3jr-cvhj-f35jghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-29201ghsaADVISORY
- github.com/xwiki/xwiki-commons/commit/4a185e0594d90cd4916d60aa60bb4333dc5623b2ghsax_refsource_MISCWEB
- github.com/xwiki/xwiki-commons/commit/b11eae9d82cb53f32962056b5faa73f3720c6182ghsax_refsource_MISCWEB
- github.com/xwiki/xwiki-commons/security/advisories/GHSA-m3jr-cvhj-f35jghsax_refsource_CONFIRMWEB
- jira.xwiki.org/browse/XCOMMONS-1680ghsax_refsource_MISCWEB
- jira.xwiki.org/browse/XCOMMONS-2426ghsax_refsource_MISCWEB
- jira.xwiki.org/browse/XWIKI-9118ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.