Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions jspwiki-main/src/main/java/org/apache/wiki/plugin/IfPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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 );
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,6 @@

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;
Expand All @@ -42,6 +36,9 @@
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;


/**
Expand All @@ -65,7 +62,6 @@
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;
Expand Down Expand Up @@ -194,12 +190,13 @@
// 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 ) {
Expand Down Expand Up @@ -258,10 +255,12 @@
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;
}

Expand Down
142 changes: 142 additions & 0 deletions jspwiki-main/src/main/java/org/apache/wiki/util/TimeLimitedRegex.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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
* <tt>{@value}</tt>.
*/
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();
}

}
}
Original file line number Diff line number Diff line change
@@ -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)));
}

}
Loading