diff --git a/jspwiki-main/src/main/java/org/apache/wiki/plugin/IfPlugin.java b/jspwiki-main/src/main/java/org/apache/wiki/plugin/IfPlugin.java index 5c33257449..15bbb640fc 100644 --- a/jspwiki-main/src/main/java/org/apache/wiki/plugin/IfPlugin.java +++ b/jspwiki-main/src/main/java/org/apache/wiki/plugin/IfPlugin.java @@ -20,12 +20,6 @@ Licensed to the Apache Software Foundation (ASF) under one package org.apache.wiki.plugin; import org.apache.commons.lang3.StringUtils; -import org.apache.oro.text.regex.MalformedPatternException; -import org.apache.oro.text.regex.Pattern; -import org.apache.oro.text.regex.PatternCompiler; -import org.apache.oro.text.regex.PatternMatcher; -import org.apache.oro.text.regex.Perl5Compiler; -import org.apache.oro.text.regex.Perl5Matcher; import org.apache.wiki.api.core.Context; import org.apache.wiki.api.exceptions.PluginException; import org.apache.wiki.api.plugin.Plugin; @@ -41,7 +35,10 @@ Licensed to the Apache Software Foundation (ASF) under one import java.util.Locale; import java.util.Map; import java.util.ResourceBundle; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import org.apache.wiki.i18n.InternationalizationManager; +import org.apache.wiki.util.TimeLimitedRegex; /** * The IfPlugin allows parts of a WikiPage to be executed conditionally, and is intended as a flexible way @@ -294,13 +291,17 @@ private static boolean checkIP( final Context context, final String ipaddr ) { } private static boolean doMatch( final String content, final String pattern ) throws PluginException { - final PatternCompiler compiler = new Perl5Compiler(); - final PatternMatcher matcher = new Perl5Matcher(); + try { - final Pattern matchp = compiler.compile( pattern, Perl5Compiler.SINGLELINE_MASK ); - return matcher.matches( content, matchp ); - } catch( final MalformedPatternException e ) { + // DOTALL gives '.' the same match-newlines behaviour as the previous Perl5Compiler.SINGLELINE_MASK. + // The match is time-boxed: pattern and page content are both author-controlled, so an unbounded + // backtracking match is a render-time denial of service (see TimeLimitedRegex). + final Pattern matchp = Pattern.compile( pattern, Pattern.DOTALL ); + return TimeLimitedRegex.matches( content, matchp ); + //final Pattern matchp = compiler.compile( pattern, Perl5Compiler.SINGLELINE_MASK ); + //return matcher.matches( content, matchp ); + } catch( final PatternSyntaxException e ) { throw new PluginException( "Faulty pattern " + pattern ); } diff --git a/jspwiki-main/src/main/java/org/apache/wiki/plugin/ReferredPagesPlugin.java b/jspwiki-main/src/main/java/org/apache/wiki/plugin/ReferredPagesPlugin.java index de48f412d3..9a1c67b580 100644 --- a/jspwiki-main/src/main/java/org/apache/wiki/plugin/ReferredPagesPlugin.java +++ b/jspwiki-main/src/main/java/org/apache/wiki/plugin/ReferredPagesPlugin.java @@ -20,12 +20,6 @@ Licensed to the Apache Software Foundation (ASF) under one import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.oro.text.regex.MalformedPatternException; -import org.apache.oro.text.regex.Pattern; -import org.apache.oro.text.regex.PatternCompiler; -import org.apache.oro.text.regex.PatternMatcher; -import org.apache.oro.text.regex.Perl5Compiler; -import org.apache.oro.text.regex.Perl5Matcher; import org.apache.wiki.api.core.Context; import org.apache.wiki.api.core.ContextEnum; import org.apache.wiki.api.core.Engine; @@ -42,6 +36,9 @@ Licensed to the Apache Software Foundation (ASF) under one import java.util.Locale; import java.util.Map; import java.util.ResourceBundle; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import org.apache.wiki.util.TimeLimitedRegex; /** @@ -65,7 +62,6 @@ public class ReferredPagesPlugin implements Plugin { private int m_depth; private final HashSet< String > m_exists = new HashSet<>(); private final StringBuffer m_result = new StringBuffer( 1024 ); - private final PatternMatcher m_matcher = new Perl5Matcher(); private Pattern m_includePattern; private Pattern m_excludePattern; private int items; @@ -194,12 +190,13 @@ public String execute( final Context context, final Map< String, String > params // glob compiler : * is 0..n instance of any char -- more convenient as input // perl5 compiler : .* is 0..n instances of any char -- more powerful //PatternCompiler g_compiler = new GlobCompiler(); - final PatternCompiler compiler = new Perl5Compiler(); try { - m_includePattern = compiler.compile( includePattern ); - m_excludePattern = compiler.compile( excludePattern ); - } catch( final MalformedPatternException e ) { + m_includePattern = Pattern.compile( includePattern ); + m_excludePattern = Pattern.compile( excludePattern ); + // m_includePattern = compiler.compile( includePattern ); + // m_excludePattern = compiler.compile( excludePattern ); + } catch( final PatternSyntaxException e ) { if( m_includePattern == null ) { throw new PluginException( "Illegal include pattern detected." ); } else if( m_excludePattern == null ) { @@ -258,10 +255,12 @@ private void handleLinks( final Context context, final Collection links, if( !m_engine.getManager( PageManager.class ).wikiPageExists( link ) ) { continue; // hide links to non-existing pages } - if( m_matcher.matches( link , m_excludePattern ) ) { + // author-controlled patterns: time-box each match so a pathological + // regexp can't pin the rendering thread (see TimeLimitedRegex) + if (TimeLimitedRegex.matches(link, m_excludePattern)) { continue; } - if( !m_matcher.matches( link , m_includePattern ) ) { + if (!TimeLimitedRegex.matches(link, m_includePattern)) { continue; } diff --git a/jspwiki-main/src/main/java/org/apache/wiki/util/TimeLimitedRegex.java b/jspwiki-main/src/main/java/org/apache/wiki/util/TimeLimitedRegex.java new file mode 100644 index 0000000000..30b1fe4a65 --- /dev/null +++ b/jspwiki-main/src/main/java/org/apache/wiki/util/TimeLimitedRegex.java @@ -0,0 +1,142 @@ +/* + 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.util; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Evaluates {@link java.util.regex} matches with an upper bound on matching + * time. The JDK regex engine backtracks, so a pathological pattern such as + * {@code (a+)+$} matched against a long non-matching subject takes exponential + * time and pins a CPU ("catastrophic backtracking" / ReDoS). When either the + * pattern or the subject is user-controlled — as with wiki plugin parameters + * matched against page content — every match must be time-boxed. + *

+ * The subject is wrapped in a deadline-checking {@link CharSequence}: the + * engine reads the subject on every matching step, so a runaway match is + * aborted shortly after the deadline passes and reported as a non-match. + * + * @since 3.0.1 + */ +public final class TimeLimitedRegex { + + private static final Logger LOG = LogManager.getLogger(TimeLimitedRegex.class); + + /** + * Default time budget for a single match, in milliseconds. Value is + * {@value}. + */ + public static final long DEFAULT_TIMEOUT_MILLIS = 1_000L; + + /** + * The deadline is re-checked every this many character accesses; must be a + * power of two. + */ + private static final int CHECK_MASK = 1_024 - 1; + + private TimeLimitedRegex() { + } + + /** + * Attempts to match the whole subject against the given pattern, giving up + * after {@link #DEFAULT_TIMEOUT_MILLIS} milliseconds. + * + * @param subject the character sequence to match. + * @param pattern the compiled pattern. + * @return {@code true} if the whole subject matches; {@code false} if it + * doesn't, or if the time budget was exceeded. + */ + public static boolean matches(final CharSequence subject, final Pattern pattern) { + return matches(subject, pattern, DEFAULT_TIMEOUT_MILLIS); + } + + /** + * Attempts to match the whole subject against the given pattern, giving up + * after {@code timeoutMillis} milliseconds. A match that exceeds its budget + * is logged as a warning and treated as a non-match. + * + * @param subject the character sequence to match. + * @param pattern the compiled pattern. + * @param timeoutMillis time budget for this match, in milliseconds. + * @return {@code true} if the whole subject matches; {@code false} if it + * doesn't, or if the time budget was exceeded. + */ + public static boolean matches(final CharSequence subject, final Pattern pattern, final long timeoutMillis) { + final Matcher matcher = pattern.matcher(new DeadlineCharSequence(subject, System.nanoTime() + timeoutMillis * 1_000_000L)); + try { + return matcher.matches(); + } catch (final MatchTimeoutException e) { + LOG.warn("Regular expression '{}' exceeded its {} ms matching budget on a {} character subject; treating as no match", + pattern.pattern(), timeoutMillis, subject.length()); + return false; + } + } + + /** + * Thrown internally when the matching deadline has passed. + */ + private static final class MatchTimeoutException extends RuntimeException { + + private static final long serialVersionUID = 1L; + } + + /** + * View over a subject that aborts any regex match still reading it after + * the deadline. + */ + private static final class DeadlineCharSequence implements CharSequence { + + private final CharSequence m_subject; + private final long m_deadlineNanos; + private int m_accesses; + + DeadlineCharSequence(final CharSequence subject, final long deadlineNanos) { + m_subject = subject; + m_deadlineNanos = deadlineNanos; + } + + @Override + public char charAt(final int index) { + if ((++m_accesses & CHECK_MASK) == 0 && System.nanoTime() - m_deadlineNanos > 0) { + throw new MatchTimeoutException(); + } + return m_subject.charAt(index); + } + + @Override + public int length() { + return m_subject.length(); + } + + @Override + public CharSequence subSequence(final int start, final int end) { + return new DeadlineCharSequence(m_subject.subSequence(start, end), m_deadlineNanos); + } + + @Override + public String toString() { + return m_subject.toString(); + } + + } +} \ No newline at end of file diff --git a/jspwiki-main/src/test/java/org/apache/wiki/util/TimeLimitedRegexTest.java b/jspwiki-main/src/test/java/org/apache/wiki/util/TimeLimitedRegexTest.java new file mode 100644 index 0000000000..76b2b87f6d --- /dev/null +++ b/jspwiki-main/src/test/java/org/apache/wiki/util/TimeLimitedRegexTest.java @@ -0,0 +1,46 @@ +/* + 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.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.regex.Pattern; + +public class TimeLimitedRegexTest { + + @Test + public void testBehavesLikePatternMatches() { + final Pattern pattern = Pattern.compile(".*needle.*", Pattern.DOTALL); + Assertions.assertTrue(TimeLimitedRegex.matches("haystack with a\nneedle in it", pattern)); + Assertions.assertFalse(TimeLimitedRegex.matches("haystack without one", pattern)); + } + + @Test + public void testCatastrophicBacktrackingIsCutOff() { + // Classic ReDoS pattern: exponential backtracking on a subject that almost matches. + // Unbounded, this match would take longer than the age of the universe. + final Pattern evil = Pattern.compile("(a+)+$"); + final String subject = "a".repeat(50_000) + "!"; + Assertions.assertTimeoutPreemptively(Duration.ofSeconds(3), + () -> Assertions.assertFalse(TimeLimitedRegex.matches(subject, evil, 1_000L))); + } + +}