diff --git a/jspwiki-markdown/src/main/java/org/apache/wiki/markdown/extensions/attributesanitizer/AttributeSanitizerExtension.java b/jspwiki-markdown/src/main/java/org/apache/wiki/markdown/extensions/attributesanitizer/AttributeSanitizerExtension.java new file mode 100644 index 0000000000..fe7b0fe6e6 --- /dev/null +++ b/jspwiki-markdown/src/main/java/org/apache/wiki/markdown/extensions/attributesanitizer/AttributeSanitizerExtension.java @@ -0,0 +1,117 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ +package org.apache.wiki.markdown.extensions.attributesanitizer; + +import com.vladsch.flexmark.html.AttributeProvider; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.html.IndependentAttributeProviderFactory; +import com.vladsch.flexmark.html.renderer.AttributablePart; +import com.vladsch.flexmark.html.renderer.LinkResolverContext; +import com.vladsch.flexmark.util.ast.Node; +import com.vladsch.flexmark.util.data.MutableDataHolder; +import com.vladsch.flexmark.util.html.MutableAttributes; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.Set; + +/** + * {@link HtmlRenderer.HtmlRendererExtension} which strips dangerous + * author-supplied attributes from the rendered HTML. Flexmark's + * AttributesExtension applies attribute lists such as + * {onclick=...} written by page authors to the rendered elements + * without any filtering, so without this extension any author could attach + * on* event handlers (stored XSS), style overlays or + * javascript: URLs to rendered content. + * {@code HtmlRenderer.ESCAPE_HTML} does not cover extension-assigned + * attributes. + * + *

+ * This extension must be registered after {@code AttributesExtension}, + * so its attribute provider runs last and sees the final attribute set of each + * node.

+ */ +public class AttributeSanitizerExtension implements HtmlRenderer.HtmlRendererExtension { + + /** + * Attributes whose value is a URL, and must therefore not carry a + * script-capable scheme. + */ + private static final Set< String> URL_ATTRIBUTES = Set.of("" + + "href", "src", "xlink:href", + "action", "formaction", + "background", "background-color", + "poster", "data", "cite"); + + public static AttributeSanitizerExtension create() { + return new AttributeSanitizerExtension(); + } + + /** + * {@inheritDoc} + */ + @Override + public void rendererOptions(final MutableDataHolder options) { + } + + /** + * {@inheritDoc} + */ + @Override + public void extend(final HtmlRenderer.Builder rendererBuilder, final String rendererType) { + rendererBuilder.attributeProviderFactory(new IndependentAttributeProviderFactory() { + + @Override + public AttributeProvider apply(final LinkResolverContext context) { + return AttributeSanitizerExtension::sanitize; + } + + }); + } + + /** + * Removes every on* event handler, the style and + * srcdoc attributes, and any URL-valued attribute whose value + * carries a script-capable scheme. + * + * @param node the node being rendered + * @param part the attributable part of the node + * @param attributes the final attribute set of the node, mutated in place + */ + static void sanitize(final Node node, final AttributablePart part, final MutableAttributes attributes) { + for (final String name : new ArrayList<>(attributes.keySet())) { + final String attribute = name.trim().toLowerCase(Locale.ENGLISH); + if (attribute.startsWith("on") || "style".equals(attribute) || "srcdoc".equals(attribute)) { + attributes.remove(name); + } else if (URL_ATTRIBUTES.contains(attribute) && hasForbiddenScheme(attributes.getValue(name))) { + attributes.remove(name); + } + } + } + + static boolean hasForbiddenScheme(final String value) { + if (value == null) { + return false; + } + // browsers ignore ASCII control characters and whitespace when parsing URL schemes, so strip them first + final String url = value.toLowerCase(Locale.ENGLISH).replaceAll("[\\x00-\\x20]", ""); + return url.startsWith("javascript:") || url.startsWith("vbscript:") || url.startsWith("data:"); + } + +} diff --git a/jspwiki-markdown/src/main/java/org/apache/wiki/parser/markdown/MarkdownDocument.java b/jspwiki-markdown/src/main/java/org/apache/wiki/parser/markdown/MarkdownDocument.java index 639b686f87..abacd84bf1 100644 --- a/jspwiki-markdown/src/main/java/org/apache/wiki/parser/markdown/MarkdownDocument.java +++ b/jspwiki-markdown/src/main/java/org/apache/wiki/parser/markdown/MarkdownDocument.java @@ -33,6 +33,7 @@ Licensed to the Apache Software Foundation (ASF) under one import org.apache.wiki.api.core.Context; import org.apache.wiki.api.core.Page; import org.apache.wiki.markdown.MarkdownForJSPWikiExtension; +import org.apache.wiki.markdown.extensions.attributesanitizer.AttributeSanitizerExtension; import org.apache.wiki.parser.MarkupParser; import org.apache.wiki.parser.WikiDocument; @@ -76,7 +77,10 @@ public static MutableDataSet options( final Context context, final boolean isIma DefinitionExtension.create(), FootnoteExtension.create(), TablesExtension.create(), - TocExtension.create() } ) ); + TocExtension.create(), + // must be registered last, so its attribute provider sees (and can strip) + // the author-supplied attributes assigned by AttributesExtension + AttributeSanitizerExtension.create() } ) ); return options; } diff --git a/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/AttributeSanitizerTest.java b/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/AttributeSanitizerTest.java new file mode 100644 index 0000000000..b1f95d0e2c --- /dev/null +++ b/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/AttributeSanitizerTest.java @@ -0,0 +1,87 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ +package org.apache.wiki.render.markdown; + +import org.apache.wiki.HttpMockFactory; +import org.apache.wiki.TestEngine; +import org.apache.wiki.api.core.Context; +import org.apache.wiki.api.core.Page; +import org.apache.wiki.api.spi.Wiki; +import org.apache.wiki.parser.markdown.MarkdownParser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.StringReader; + + +/** + * Regression tests for the markdown attribute sanitizer: author-written flexmark attribute lists must not be able + * to attach event handlers, styles or script-scheme URLs to rendered elements. + */ +public class AttributeSanitizerTest { + + static final String PAGE_NAME = "attributesanitizertestpage"; + + TestEngine testEngine = TestEngine.build( TestEngine.with( "jspwiki.fileSystemProvider.pageDir", "./target/md-sanitizer-pageDir" ), + TestEngine.with( "jspwiki.renderingManager.markupParser", MarkdownParser.class.getName() ), + TestEngine.with( "jspwiki.renderingManager.renderer", MarkdownRenderer.class.getName() ) ); + + @Test + public void testEventHandlerAttributeIsStripped() throws Exception { + final String html = translate( "Click me{onmouseover=alert(document.cookie)}" ); + Assertions.assertFalse( html.contains( "onmouseover" ), html ); + Assertions.assertFalse( html.contains( "document.cookie" ), html ); + } + + @Test + public void testEventHandlerCaseVariationIsStripped() throws Exception { + final String html = translate( "Click me{ONCLICK=alert(1)}" ); + Assertions.assertFalse( html.toLowerCase().contains( "onclick" ), html ); + } + + @Test + public void testStyleAttributeIsStripped() throws Exception { + final String html = translate( "Overlay{style=position:fixed;top:0;left:0;width:100vw;height:100vh}" ); + Assertions.assertFalse( html.contains( "style=" ), html ); + Assertions.assertFalse( html.contains( "position:fixed" ), html ); + } + + @Test + public void testJavascriptHrefIsStripped() throws Exception { + final String html = translate( "[link](x){href=javascript:alert(1)}" ); + Assertions.assertFalse( html.contains( "javascript:" ), html ); + } + + @Test + public void testBenignAttributesSurvive() throws Exception { + final String html = translate( "Text{#anchor .highlight}" ); + Assertions.assertTrue( html.contains( "anchor" ), html ); + Assertions.assertTrue( html.contains( "highlight" ), html ); + } + + String translate( final String src ) throws Exception { + final Page page = Wiki.contents().page( testEngine, PAGE_NAME ); + final Context context = Wiki.context().create( testEngine, HttpMockFactory.createHttpRequest(), page ); + final MarkdownParser tr = new MarkdownParser( context, new BufferedReader( new StringReader( src ) ) ); + final MarkdownRenderer conv = new MarkdownRenderer( context, tr.parse() ); + return conv.getString(); + } + +} diff --git a/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/MarkdownRendererTest.java b/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/MarkdownRendererTest.java index 2db4dd4e13..853a8d8112 100644 --- a/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/MarkdownRendererTest.java +++ b/jspwiki-markdown/src/test/java/org/apache/wiki/render/markdown/MarkdownRendererTest.java @@ -149,7 +149,7 @@ public void testMarkupLinkWithCustomAttributes() throws Exception { public void testMarkupPWithCustomAttributes() throws Exception { // {..} are separated from the link, so they apply to the nearest p or span containing them final String src0 = "This should be a [link](http://google.com) {style='background-color:#ddd'}"; - Assertions.assertEquals( "

This should be a link

\n", translate( src0 ) ); + Assertions.assertEquals( "

This should be a link

\n", translate( src0 ) ); final String src1 = "This should be a [link](http://google.com) {#a1}"; Assertions.assertEquals( "

This should be a link

\n", translate( src1 ) );