/**
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright ownership. Apereo
* 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 the
* following location:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>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.apereo.portal.security.firewall;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
/**
* This is a Java Servlet Filter that examines specified Request Parameters as to whether they
* contain specified characters and as to whether they are multivalued throws an Exception if they
* do not meet configured rules.
*
* <p>This is a fork of the Filter offered at
* https://github.com/Jasig/cas-server-security-filter/pull/6 .
*
* <p>This is forked into the uPortal project source code directly so that it can be used to patch
* the uPortal usage of the Java CAS Client against CVE-2014-4172 without requiring upgrade of the
* Java CAS Client library itself.
*
* <p>Configuration:
*
* <p>The filter defaults to checking all request parameters for the hash, percent, question mark,
* and ampersand characters, and enforcing no-multi-valued-ness.
*
* <p>You can turn off multi-value checking by setting the init-param "allowMultiValuedParameters"
* to "true". Setting it to "false" is a no-op retaining the default configuration. Setting this
* parameter to any other value fails filter initialization.
*
* <p>You can change the set of request parameters being examined by setting the init-param
* "parametersToCheck" to a whitespace delimited list of parameters to check. Setting it to the
* special value "*" retains the default behavior of checking all. Setting it to a blank value fails
* filter initialization. Setting it to a String containing the asterisk token and any additional
* token fails filter initialization.
*
* <p>You can change the set of characters looked for by setting the init-param "charactersToForbid"
* to a whitespace delimited list of characters to forbid. Setting it to the special value "none"
* disables the illicit character blocking feature of this Filter (for the case where you only want
* to use the mutli-valued-ness blocking). Setting it to a blank value fails filter initialization.
* Setting it to a value that fails to parse perfectly (e.g., a value with multi-character Strings
* between the whitespace delimiters) fails filter initialization. The default set of characters
* disallowed is percent, hash, question mark, and ampersand.
*
* <p>Setting any other init parameter other than these recognized by this Filter will fail Filter
* initialization. This is to protect the adopter from typos or misunderstandings in web.xml
* configuration such that an intended configuration might not have taken effect, since that might
* have security implications.
*
* <p>Setting the Filter to both allow multi-valued parameters and to disallow no characters would
* make the Filter a no-op, and so fails Filter initialization since you probably meant the Filter
* to be doing something.
*
* <p>The intent of this filter is rough, brute force blocking of unexpected characters in specific
* CAS protocol related request parameters. This is one option as a workaround for patching in place
* certain Java CAS Client versions that may be vulnerable to certain attacks involving crafted
* request parameter values that may be mishandled. This is also suitable for patching certain CAS
* Server versions to make more of an effort to detect and block out-of-spec CAS protocol requests.
* Aside from the intent to be useful for those cases, there is nothing CAS-specific about this
* Filter itself. This is a generic Filter for doing some pretty basic generic sanity checking on
* request parameters. It might come in handy the next time this kind of issue arises.
*
* <p>This Filter is written to have no external .jar dependencies aside from the Servlet API
* necessary to be a Filter.
*
* <p>This class is declared final because it is not designed for extension.
*
* @since 4.0.15 , 4.1.1
*/
public final class RequestParameterPolicyEnforcementFilter implements Filter {
/**
* The set of Characters blocked by default on checked parameters. Expressed as a whitespace
* delimited set of characters.
*/
public static final String DEFAULT_CHARACTERS_BLOCKED = "? & # %";
/**
* The name of the optional Filter init-param specifying what request parameters ought to be
* checked. The value is a whitespace delimited set of parameters. The exact value '*' has the
* special meaning of matching all parameters, and is the default behavior.
*/
public static final String PARAMETERS_TO_CHECK = "parametersToCheck";
/**
* The name of the optional Filter init-param specifying what characters are forbidden in the
* checked request parameters. The value is a whitespace delimited set of such characters.
*/
public static final String CHARACTERS_TO_FORBID = "charactersToForbid";
/**
* The name of the optional Filter init-param specifying whether the checked request parameters
* are allowed to have multiple values. Allowable values for this init parameter are `true` and
* `false`.
*/
public static final String ALLOW_MULTI_VALUED_PARAMETERS = "allowMultiValuedParameters";
/**
* Set of parameter names to check. Empty set represents special behavior of checking all
* parameters.
*/
private Set<String> parametersToCheck;
/**
* Set of characters to forbid in the checked request parameters. Empty set represents not
* forbidding any characters.
*/
private Set<Character> charactersToForbid;
/** Should checked parameters be permitted to have multiple values. */
private boolean allowMultiValueParameters = false;
/* ========================================================================================================== */
/* Filter methods */
@Override
public void init(final FilterConfig filterConfig) throws ServletException {
// verify there are no init parameters configured that are not recognized
// since an unrecognized init param might be the adopter trying to configure this filter in an important way
// and accidentally ignoring that intent might have security implications.
final Enumeration initParamNames = filterConfig.getInitParameterNames();
throwIfUnrecognizedParamName(initParamNames);
final String initParamAllowMultiValuedParameters =
filterConfig.getInitParameter(ALLOW_MULTI_VALUED_PARAMETERS);
final String initParamParametersToCheck =
filterConfig.getInitParameter(PARAMETERS_TO_CHECK);
final String initParamCharactersToForbid =
filterConfig.getInitParameter(CHARACTERS_TO_FORBID);
try {
this.allowMultiValueParameters =
parseStringToBooleanDefaultingToFalse(initParamAllowMultiValuedParameters);
} catch (final Exception e) {
throw new ServletException(
"Error parsing request parameter ["
+ ALLOW_MULTI_VALUED_PARAMETERS
+ "] with value ["
+ initParamAllowMultiValuedParameters
+ "]",
e);
}
try {
this.parametersToCheck = parseParametersToCheck(initParamParametersToCheck);
} catch (final Exception e) {
throw new ServletException(
"Error parsing request parameter "
+ PARAMETERS_TO_CHECK
+ " with value ["
+ initParamParametersToCheck
+ "]",
e);
}
try {
this.charactersToForbid = parseCharactersToForbid(initParamCharactersToForbid);
} catch (final Exception e) {
throw new ServletException(
"Error parsing request parameter "
+ CHARACTERS_TO_FORBID
+ " with value ["
+ initParamCharactersToForbid
+ "]",
e);
}
if (this.allowMultiValueParameters && this.charactersToForbid.isEmpty()) {
throw new ServletException(
"Configuration to allow multi-value parameters and forbid no characters makes "
+ getClass().getSimpleName()
+ " a no-op, which is probably not what you want, "
+ "so failing Filter init.");
}
}
@Override
public void doFilter(
final ServletRequest request, final ServletResponse response, final FilterChain chain)
throws IOException, ServletException {
try {
if (request instanceof HttpServletRequest) {
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
// immutable map from String param name --> String[] parameter values
final Map parameterMap = httpServletRequest.getParameterMap();
// which parameters *on this request* ought to be checked.
final Set<String> parametersToCheckHere;
if (this.parametersToCheck.isEmpty()) {
// the special meaning of empty is to check *all* the parameters, so
parametersToCheckHere = parameterMap.keySet();
} else {
parametersToCheckHere = this.parametersToCheck;
}
if (!this.allowMultiValueParameters) {
requireNotMultiValued(parametersToCheckHere, parameterMap);
}
enforceParameterContentCharacterRestrictions(
parametersToCheckHere, this.charactersToForbid, parameterMap);
}
} catch (final Exception e) {
// translate to a ServletException to meet the typed expectations of the Filter API.
throw new ServletException(
getClass().getSimpleName()
+ " is blocking this request. Examine the cause in"
+ " this stack trace to understand why.",
e);
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
// do nothing
}
/* ========================================================================================================== */
/* Init parameter parsing */
/**
* Examines the Filter init parameter names and throws ServletException if they contain an
* unrecognized init parameter name.
*
* <p>This is a stateless static method.
*
* <p>This method is an implementation detail and is not exposed API. This method is only
* non-private to allow JUnit testing.
*
* @param initParamNames init param names, in practice as read from the FilterConfig.
* @throws ServletException if unrecognized parameter name is present
*/
static void throwIfUnrecognizedParamName(Enumeration initParamNames) throws ServletException {
final Set<String> recognizedParameterNames = new HashSet<String>();
recognizedParameterNames.add(ALLOW_MULTI_VALUED_PARAMETERS);
recognizedParameterNames.add(PARAMETERS_TO_CHECK);
recognizedParameterNames.add(CHARACTERS_TO_FORBID);
while (initParamNames.hasMoreElements()) {
final String initParamName = (String) initParamNames.nextElement();
if (!recognizedParameterNames.contains(initParamName)) {
throw new ServletException(
"Unrecognized init parameter ["
+ initParamName
+ "]. Failing safe. Typo"
+ " in the web.xml configuration? "
+ " Misunderstanding about the configuration "
+ RequestParameterPolicyEnforcementFilter.class.getSimpleName()
+ " expects?");
}
}
}
/**
* Parse a String to a boolean.
*
* <p>"true" --> true "false" --> false null --> false Anything else --> throw
* IllegalArgumentException.
*
* <p>This is a stateless static method.
*
* <p>This method is an implementation detail and is not exposed API. This method is only
* non-private to allow JUnit testing.
*
* @param stringToParse a String to parse to a boolean as specified
* @return true or false
* @throws IllegalArgumentException if the String is not true, false, or null.
*/
static boolean parseStringToBooleanDefaultingToFalse(final String stringToParse) {
if ("true".equals(stringToParse)) {
return true;
} else if ("false".equals(stringToParse)) {
return false;
} else if (null == stringToParse) {
return false;
}
throw new IllegalArgumentException(
"String ["
+ stringToParse
+ "] could not parse to a boolean because it was not precisely 'true' or 'false'.");
}
/**
* Parse the whitespace delimited String of parameters to check.
*
* <p>If the String is null, return the empty set. If the whitespace delimited String contains
* no tokens, throw IllegalArgumentException. If the sole token is an asterisk, return the empty
* set. If the asterisk token is encountered among other tokens, throw IllegalArgumentException.
*
* <p>This method returning an empty Set has the special meaning of "check all parameters".
*
* <p>This is a stateless static method.
*
* <p>This method is an implementation detail and is not exposed API. This method is only
* non-private to allow JUnit testing.
*
* @param initParamValue null, or a non-blank whitespace delimited list of parameters to check
* @return a Set of String names of parameters to check, or an empty set representing
* check-them-all.
* @throws IllegalArgumentException when the init param value is out of spec
*/
static Set<String> parseParametersToCheck(final String initParamValue) {
final Set<String> parameterNames = new HashSet<String>();
if (null == initParamValue) {
return parameterNames;
}
final String[] tokens = initParamValue.split("\\s+");
if (0 == tokens.length) {
throw new IllegalArgumentException(
"["
+ initParamValue
+ "] had no tokens but should have had at least one token.");
}
if (1 == tokens.length && "*".equals(tokens[0])) {
return parameterNames;
}
for (final String parameterName : tokens) {
if ("*".equals(parameterName)) {
throw new IllegalArgumentException(
"Star token encountered among other tokens in parsing ["
+ initParamValue
+ "]");
}
parameterNames.add(parameterName);
}
return parameterNames;
}
/**
* Parse a whitespace delimited set of Characters from a String.
*
* <p>If the String is null (init param was not set) default to DEFAULT_CHARACTERS_BLOCKED. If
* the String is "none" parse to empty set meaning block no characters. If the String is empty
* throw, to avoid configurer accidentally configuring not to block any characters.
*
* @param paramValue value of the init param to parse
* @return non-null Set of zero or more Characters to block
*/
static Set<Character> parseCharactersToForbid(String paramValue) {
final Set<Character> charactersToForbid = new HashSet<Character>();
if (null == paramValue) {
paramValue = DEFAULT_CHARACTERS_BLOCKED;
}
if ("none".equals(paramValue)) {
return charactersToForbid;
}
final String[] tokens = paramValue.split("\\s+");
if (0 == tokens.length) {
throw new IllegalArgumentException(
"Expected tokens when parsing ["
+ paramValue
+ "] but found no tokens."
+ " If you really want to configure no characters, use the magic value 'none'.");
}
for (final String token : tokens) {
if (token.length() > 1) {
throw new IllegalArgumentException(
"Expected tokens of length 1 but found ["
+ token
+ "] when "
+ "parsing ["
+ paramValue
+ "]");
}
final Character character = token.charAt(0);
charactersToForbid.add(character);
}
return charactersToForbid;
}
/* ========================================================================================================== */
/* Filtering requests */
/**
* For each parameter to check, verify that it has zero or one value.
*
* <p>The Set of parameters to check MAY be empty. The parameter map MAY NOT contain any given
* parameter to check.
*
* <p>This method is an implementation detail and is not exposed API. This method is only
* non-private to allow JUnit testing.
*
* <p>Static, stateless method.
*
* @param parametersToCheck non-null potentially empty Set of String names of parameters
* @param parameterMap non-null Map from String name of parameter to String[] values
* @throws IllegalStateException if a parameterToCheck is present in the parameterMap with
* multiple values.
*/
static void requireNotMultiValued(final Set<String> parametersToCheck, final Map parameterMap) {
for (final String parameterName : parametersToCheck) {
if (parameterMap.containsKey(parameterName)) {
final String[] values = (String[]) parameterMap.get(parameterName);
if (values.length > 1) {
throw new IllegalStateException(
"Parameter ["
+ parameterName
+ "] had multiple values ["
+ values
+ "] but at most one value is allowable.");
}
}
}
}
/**
* For each parameter to check, for each value of that parameter, check that the value does not
* contain forbidden characters.
*
* <p>This is a stateless static method.
*
* <p>This method is an implementation detail and is not exposed API. This method is only
* non-private to allow JUnit testing.
*
* @param parametersToCheck Set of String request parameter names to look for
* @param charactersToForbid Set of Character characters to forbid
* @param parameterMap String --> String[] Map, in practice as read from ServletRequest
*/
static void enforceParameterContentCharacterRestrictions(
final Set<String> parametersToCheck,
final Set<Character> charactersToForbid,
final Map parameterMap) {
if (charactersToForbid.isEmpty()) {
// short circuit
return;
}
for (final String parameterToCheck : parametersToCheck) {
final String[] parameterValues = (String[]) parameterMap.get(parameterToCheck);
if (null != parameterValues) {
for (final String parameterValue : parameterValues) {
for (final Character forbiddenCharacter : charactersToForbid) {
final StringBuilder characterAsStringBuilder = new StringBuilder();
characterAsStringBuilder.append(forbiddenCharacter);
if (parameterValue.contains(characterAsStringBuilder)) {
throw new IllegalArgumentException(
"Disallowed character ["
+ forbiddenCharacter
+ "] found in value ["
+ parameterValue
+ "] of parameter named ["
+ parameterToCheck
+ "]");
}
// that forbiddenCharacter was not in this parameterValue
}
// none of the charactersToForbid were in this parameterValue
}
// none of the values of this parameterToCheck had a forbidden character
} // or this parameterToCheck had null value
}
// none of the values of any of the parametersToCheck had a forbidden character
// hurray! allow flow to continue without throwing an Exception.
}
}