This is an automated email from the ASF dual-hosted git repository.
rmaucher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/main by this push:
new c45c9b47f3 Optimize Locale processing
c45c9b47f3 is described below
commit c45c9b47f34837aa35cbd4d677e82440db620a00
Author: remm <[email protected]>
AuthorDate: Tue Oct 6 12:35:02 2026 +0200
Optimize Locale processing
Limit to 10 languages in Accept-Language by default.
Locale and ResourceBundle processing, although never too extreme in the
worst case, can use needless resources, so cap that at a resonable
limit.
---
java/org/apache/catalina/connector/Connector.java | 27 ++
.../catalina/connector/LocalStrings.properties | 1 +
java/org/apache/catalina/connector/Request.java | 52 +++-
.../catalina/connector/mbeans-descriptors.xml | 4 +
.../apache/catalina/tribes/util/StringManager.java | 67 ++++-
.../tomcat/util/http/parser/AcceptLanguage.java | 29 +++
.../tomcat/util/http/parser/AcceptLanguage2.java | 281 +++++++++++++++++++++
java/org/apache/tomcat/util/res/StringManager.java | 68 ++++-
.../org/apache/catalina/connector/TestRequest.java | 138 ++++++++++
.../catalina/valves/TestErrorReportValve.java | 60 +++++
.../util/http/parser/TestAcceptLanguage.java | 61 +++++
.../apache/tomcat/util/res/TestStringManager.java | 71 ++++++
webapps/docs/changelog.xml | 4 +
webapps/docs/config/ajp.xml | 8 +
webapps/docs/config/http.xml | 8 +
15 files changed, 876 insertions(+), 3 deletions(-)
diff --git a/java/org/apache/catalina/connector/Connector.java
b/java/org/apache/catalina/connector/Connector.java
index 661ddb5d0a..cb83fbda5e 100644
--- a/java/org/apache/catalina/connector/Connector.java
+++ b/java/org/apache/catalina/connector/Connector.java
@@ -244,6 +244,12 @@ public class Connector extends LifecycleMBeanBase {
*/
private int maxCookieCount = 200;
+ /**
+ * The maximum number of locales permitted for a request. Requests that
exceed this limit will be rejected when
+ * the locales are parsed. Use a value less than zero for no limit.
Defaults to 10.
+ */
+ private int maxLocaleCount = 10;
+
/**
* The maximum number of parameters (GET plus POST) which will be
automatically parsed by the container. 1000 by
* default. A value of less than 0 means no limit.
@@ -519,6 +525,27 @@ public class Connector extends LifecycleMBeanBase {
}
+ /**
+ * Returns the maximum number of locales that are permitted for a request.
Requests that exceed this limit will be
+ * rejected when the locales are parsed. A value of less than zero means
no limit.
+ * @return the maximum locale count
+ */
+ public int getMaxLocaleCount() {
+ return maxLocaleCount;
+ }
+
+
+ /**
+ * Sets the maximum number of locales that are permitted for a request.
Requests that exceed this limit will be
+ * rejected when the locales are parsed. A value of less than zero means
no limit.
+ *
+ * @param maxLocaleCount the new maximum locale count
+ */
+ public void setMaxLocaleCount(int maxLocaleCount) {
+ this.maxLocaleCount = maxLocaleCount;
+ }
+
+
/**
* Returns the maximum number of parameters (GET plus POST) that will be
automatically parsed by the container.
* A value of less than 0 means no limit.
diff --git a/java/org/apache/catalina/connector/LocalStrings.properties
b/java/org/apache/catalina/connector/LocalStrings.properties
index 4ae47e2adb..17b6cefdbc 100644
--- a/java/org/apache/catalina/connector/LocalStrings.properties
+++ b/java/org/apache/catalina/connector/LocalStrings.properties
@@ -68,6 +68,7 @@ coyoteRequest.sendfileNotCanonical=Unable to determine
canonical name of file [{
coyoteRequest.sessionCreateCommitted=Cannot create a session after the
response has been committed
coyoteRequest.sessionEndAccessFail=Exception triggered ending access to
session while recycling request
coyoteRequest.setAttribute.namenull=Cannot call setAttribute with a null name
+coyoteRequest.tooManyLocales=More than the maximum allowed number of locales,
[{0}], were detected. Use the maxLocaleCount attribute of the connector to
resolve this if the application should accept more locales.
coyoteRequest.trailersNotReady=It is illegal to call getTrailerFields() before
isTrailerFieldsReady() has returned true
coyoteRequest.uploadCreate=Creating the temporary upload location [{0}] as it
is required by the servlet [{1}]
coyoteRequest.uploadCreateFail=Failed to create the upload location [{0}]
diff --git a/java/org/apache/catalina/connector/Request.java
b/java/org/apache/catalina/connector/Request.java
index df5e9a8a8b..a5bb569def 100644
--- a/java/org/apache/catalina/connector/Request.java
+++ b/java/org/apache/catalina/connector/Request.java
@@ -115,6 +115,7 @@ import
org.apache.tomcat.util.http.fileupload.impl.InvalidContentTypeException;
import org.apache.tomcat.util.http.fileupload.impl.SizeException;
import org.apache.tomcat.util.http.fileupload.servlet.ServletRequestContext;
import org.apache.tomcat.util.http.parser.AcceptLanguage;
+import org.apache.tomcat.util.http.parser.AcceptLanguage2;
import org.apache.tomcat.util.http.parser.MediaType;
import org.apache.tomcat.util.http.parser.Upgrade;
import org.apache.tomcat.util.net.SSLSupport;
@@ -129,6 +130,18 @@ public class Request implements HttpServletRequest {
private static final String HTTP_UPGRADE_HEADER_NAME = "upgrade";
+ /**
+ * The default value of the maximum number of locales to parse from the
Accept-Language header(s). Must match the
+ * default of the maxLocaleCount attribute of the Connector with which a
Request is associated.
+ */
+ private static final int DEFAULT_MAX_LOCALE_COUNT = 10;
+
+ /*
+ * Use AcceptLanguage2 parser rather than the StandardAcceptLanguageParser.
+ */
+ private static final boolean USE_STANDARD_ACCEPT_LANGUAGE_PARSER =
Boolean.parseBoolean(
+
System.getProperty("org.apache.tomcat.util.http.parser.StandardAcceptLanguageParser",
"false"));
+
private static final Log log = LogFactory.getLog(Request.class);
/**
@@ -147,6 +160,7 @@ public class Request implements HttpServletRequest {
maxParameterCount = connector.getMaxParameterCount();
maxPartCount = connector.getMaxPartCount();
maxPartHeaderSize = connector.getMaxPartHeaderSize();
+ maxLocaleCount = connector.getMaxLocaleCount();
}
this.coyoteRequest = coyoteRequest;
inputBuffer = new InputBuffer(coyoteRequest);
@@ -429,6 +443,15 @@ public class Request implements HttpServletRequest {
private int maxPartHeaderSize = -1;
+ /**
+ * The maximum number of locales permitted for a request. The
Accept-Language header value is untrusted input
+ * bounded only by the maximum header size so the number of locales is
bounded by default to limit the
+ * per-request processing and the number of distinct locales exposed to
web applications and internal components.
+ * Requests that exceed this limit are rejected when the locales are
parsed. A value of less than zero means no
+ * limit.
+ */
+ private int maxLocaleCount = DEFAULT_MAX_LOCALE_COUNT;
+
// --------------------------------------------------------- Public Methods
/**
@@ -479,10 +502,12 @@ public class Request implements HttpServletRequest {
maxParameterCount = connector.getMaxParameterCount();
maxPartCount = connector.getMaxPartCount();
maxPartHeaderSize = connector.getMaxPartHeaderSize();
+ maxLocaleCount = connector.getMaxLocaleCount();
} else {
maxParameterCount = -1;
maxPartCount = -1;
maxPartHeaderSize = -1;
+ maxLocaleCount = DEFAULT_MAX_LOCALE_COUNT;
}
if (parts != null) {
for (Part part : parts) {
@@ -3100,6 +3125,10 @@ public class Request implements HttpServletRequest {
// negating the Double value when creating the key)
for (ArrayList<Locale> list : locales.values()) {
for (Locale locale : list) {
+ if (maxLocaleCount > -1 && this.locales.size() >=
maxLocaleCount) {
+ throw new IllegalArgumentException(
+ sm.getString("coyoteRequest.tooManyLocales",
Integer.valueOf(maxLocaleCount)));
+ }
addLocale(locale);
}
}
@@ -3114,9 +3143,30 @@ public class Request implements HttpServletRequest {
*/
protected void parseLocalesHeader(String value,
TreeMap<Double,ArrayList<Locale>> locales) {
+ // Determine how many more entries are of interest. One more than the
+ // maximum is parsed so that a header that exceeds the limit is
+ // rejected rather than silently truncated. Parsing no further entries
+ // once the limit is reached avoids creating Locale objects for entries
+ // that can never be used.
+ int maxElements = -1;
+ if (maxLocaleCount > -1) {
+ int current = 0;
+ for (ArrayList<Locale> list : locales.values()) {
+ current += list.size();
+ }
+ maxElements = maxLocaleCount + 1 - current;
+ if (maxElements < 1) {
+ return;
+ }
+ }
+
List<AcceptLanguage> acceptLanguages;
try {
- acceptLanguages = AcceptLanguage.parse(new StringReader(value));
+ if (USE_STANDARD_ACCEPT_LANGUAGE_PARSER) {
+ acceptLanguages = AcceptLanguage.parse(new
StringReader(value), maxElements);
+ } else {
+ acceptLanguages = AcceptLanguage2.parse(new
StringReader(value), maxElements);
+ }
} catch (IOException ioe) {
// Mal-formed headers are ignore. Do the same in the unlikely event
// of an IOException.
diff --git a/java/org/apache/catalina/connector/mbeans-descriptors.xml
b/java/org/apache/catalina/connector/mbeans-descriptors.xml
index 66b5f4fc82..da13e4a11d 100644
--- a/java/org/apache/catalina/connector/mbeans-descriptors.xml
+++ b/java/org/apache/catalina/connector/mbeans-descriptors.xml
@@ -106,6 +106,10 @@
description="Maximum number of Keep-Alive requests to honor per
connection"
type="int"/>
+ <attribute name="maxLocaleCount"
+ description="The maximum number of locales that are permitted for a
request. Requests that exceed this limit will be rejected when the locales are
parsed. 10 by default. A value of less than 0 means no limit."
+ type="int"/>
+
<attribute name="maxParameterCount"
description="The maximum number of parameters (GET plus POST) which
will be automatically parsed by the container. 1000 by default. A value of less
than 0 means no limit."
type="int"/>
diff --git a/java/org/apache/catalina/tribes/util/StringManager.java
b/java/org/apache/catalina/tribes/util/StringManager.java
index 9363ae7fcc..51dd846e45 100644
--- a/java/org/apache/catalina/tribes/util/StringManager.java
+++ b/java/org/apache/catalina/tribes/util/StringManager.java
@@ -46,6 +46,9 @@ public class StringManager {
private static final int LOCALE_CACHE_SIZE = 10;
+ private static final ResourceBundle.Control BUNDLE_CONTROL =
+
ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
+
/**
* The ResourceBundle for this StringManager.
*/
@@ -246,12 +249,74 @@ public class StringManager {
public static StringManager getManager(String packageName,
Enumeration<Locale> requestedLocales) {
while (requestedLocales.hasMoreElements()) {
Locale locale = requestedLocales.nextElement();
+ // Only look up locales that can actually match.
+ if (!hasBundle(packageName, locale)) {
+ continue;
+ }
StringManager result = getManager(packageName, locale);
- if (result.getLocale().equals(locale)) {
+ if (result.getLocale() != null &&
result.getLocale().equals(locale)) {
return result;
}
}
// Return the default
return getManager(packageName);
}
+
+
+ /**
+ * Determines if a requested locale can ever match. Only locales for which
an exact match bundle exists can
+ * match so only those should be passed to the constructor.
+ *
+ * @param packageName The package name
+ * @param locale The requested locale
+ *
+ * @return true if the locale should be looked up
+ */
+ private static boolean hasBundle(String packageName, Locale locale) {
+ String bundleName = packageName + ".LocalStrings";
+ if (bundleExists(bundleName, locale)) {
+ return true;
+ }
+ // A lookup for plain "en" with no exact match falls back to the
+ // root bundle which is reported as Locale.ENGLISH and therefore
+ // matches. Locale.ROOT is a single, fixed cache key.
+ return locale.equals(Locale.ENGLISH);
+ }
+
+
+ /**
+ * Checks if a bundle exists for the given locale without adding an entry
to the JVM-wide ResourceBundle cache.
+ *
+ * @param bundleName The full bundle name
+ * @param locale The locale
+ *
+ * @return true if a bundle exists for the given locale
+ */
+ private static boolean bundleExists(String bundleName, Locale locale) {
+ ClassLoader classLoader = StringManager.class.getClassLoader();
+ if (bundleExists(bundleName, locale, classLoader)) {
+ return true;
+ }
+ // The constructor also falls back to the current thread context
+ // class loader so probe it too.
+ ClassLoader contextClassLoader =
Thread.currentThread().getContextClassLoader();
+ if (contextClassLoader != null && contextClassLoader != classLoader) {
+ return bundleExists(bundleName, locale, contextClassLoader);
+ }
+ return false;
+ }
+
+
+ private static boolean bundleExists(String bundleName, Locale locale,
ClassLoader classLoader) {
+ try {
+ // Control.newBundle() loads the bundle (or returns null) without
+ // caching the result in the JVM-wide ResourceBundle cache.
+ return BUNDLE_CONTROL.newBundle(bundleName, locale,
+ ResourceBundle.Control.FORMAT_PROPERTIES.get(0),
classLoader, false) != null;
+ } catch (Exception ignore) {
+ // Any load failure is treated as "no bundle" which matches the
+ // behaviour of the constructor.
+ return false;
+ }
+ }
}
diff --git a/java/org/apache/tomcat/util/http/parser/AcceptLanguage.java
b/java/org/apache/tomcat/util/http/parser/AcceptLanguage.java
index 0f2783f61a..796ec13d5d 100644
--- a/java/org/apache/tomcat/util/http/parser/AcceptLanguage.java
+++ b/java/org/apache/tomcat/util/http/parser/AcceptLanguage.java
@@ -68,10 +68,39 @@ public class AcceptLanguage {
* @throws IOException If an I/O error occurs while reading the input
*/
public static List<AcceptLanguage> parse(StringReader input) throws
IOException {
+ return parse(input, -1);
+ }
+
+
+ /**
+ * Parses an Accept-Language header value, reading at most the specified
+ * number of entries. Parsing stops as soon as the maximum number of
+ * entries has been read. This limits the number of Locale objects created
+ * when the caller has no use for entries beyond the limit (e.g. when a
+ * container level limit means the request is going to be rejected).
+ *
+ * @param input The StringReader containing the header value
+ * @param maxElements The maximum number of entries to parse or
+ * <code>-1</code> for no limit
+ *
+ * @return A list of AcceptLanguage entries sorted by quality. If the limit
+ * was reached, the returned list has exactly
+ * <code>maxElements</code> entries and any remaining entries were
+ * not parsed.
+ *
+ * @throws IOException If an I/O error occurs while reading the input
+ */
+ public static List<AcceptLanguage> parse(StringReader input, int
maxElements) throws IOException {
List<AcceptLanguage> result = new ArrayList<>();
do {
+ if (maxElements >= 0 && result.size() >= maxElements) {
+ // Enough entries have been read. Do not parse any further
+ // entries to avoid unnecessary object creation.
+ break;
+ }
+
// Token is broader than what is permitted in a language tag
// (alphanumeric + '-') but any invalid values that slip through
// will be caught later
diff --git a/java/org/apache/tomcat/util/http/parser/AcceptLanguage2.java
b/java/org/apache/tomcat/util/http/parser/AcceptLanguage2.java
new file mode 100644
index 0000000000..cb7f39d3fd
--- /dev/null
+++ b/java/org/apache/tomcat/util/http/parser/AcceptLanguage2.java
@@ -0,0 +1,281 @@
+/*
+ * 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.tomcat.util.http.parser;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Parser for {@link AcceptLanguage} that parses an Accept-Language header
+ * value using the deprecated {@link Locale#Locale(String, String, String)}
+ * constructor, combined with a custom BCP 47 subtag parser that maps
+ * the language tag components onto the language/country/variant triple that
+ * the deprecated constructor accepts. This is done for better efficiency.
+ * <p>
+ * Known, accepted degradations compared with
+ * {@code Locale.forLanguageTag()}:
+ * <ul>
+ * <li>Unicode, other and private use extensions (e.g. {@code
-u-ca-buddhist},
+ * {@code -x-priv}) are discarded rather than recorded on the
Locale.</li>
+ * <li>Script subtags (e.g. {@code -Hant-}) cannot be represented by the
+ * deprecated constructor and are mapped to the Locale variant, in upper
+ * case, instead of to the Locale script.</li>
+ * <li>Tags that are syntactically invalid after the language subtag retain
+ * the components parsed up to that point rather than being replaced with
+ * {@link Locale#ROOT}.</li>
+ * </ul>
+ */
+public final class AcceptLanguage2 {
+
+ private AcceptLanguage2() {
+ // Utility class
+ }
+
+ /**
+ * Parses an Accept-Language header value, reading at most the specified
+ * number of entries. See
+ * {@link AcceptLanguage#parse(StringReader, int)}.
+ *
+ * @param input The StringReader containing the header value
+ * @param maxElements The maximum number of entries to parse or
+ * <code>-1</code> for no limit
+ *
+ * @return A list of AcceptLanguage entries. If the limit was reached, the
+ * returned list has exactly <code>maxElements</code> entries and
+ * any remaining entries were not parsed.
+ *
+ * @throws IOException If an I/O error occurs while reading the input
+ */
+ public static List<AcceptLanguage> parse(StringReader input, int
maxElements) throws IOException {
+
+ List<AcceptLanguage> result = new ArrayList<>();
+
+ do {
+ if (maxElements >= 0 && result.size() >= maxElements) {
+ // Enough entries have been read. Do not parse any further
+ // entries to avoid unnecessary object creation.
+ break;
+ }
+
+ // Token is broader than what is permitted in a language tag
+ // (alphanumeric + '-') but any invalid values that slip through
+ // are treated as Locale.ROOT by toLegacyLocale(), matching
+ // Locale.forLanguageTag().
+ String languageTag = HttpParser.readToken(input);
+ if (languageTag == null) {
+ // Invalid tag, skip to the next one
+ HttpParser.skipUntil(input, 0, ',');
+ continue;
+ }
+
+ if (languageTag.isEmpty()) {
+ // No more data to read
+ break;
+ }
+
+ // See if a quality has been provided
+ double quality = 1;
+ SkipResult lookForSemiColon = HttpParser.skipConstant(input, ";");
+ if (lookForSemiColon == SkipResult.FOUND) {
+ quality = HttpParser.readWeight(input, ',');
+ }
+
+ if (quality > 0) {
+ result.add(new AcceptLanguage(toLegacyLocale(languageTag),
quality));
+ }
+ } while (true);
+
+ return result;
+ }
+
+
+ /**
+ * Maps a BCP 47 language tag onto a Locale using the deprecated Locale
+ * constructor.
+ * <p>
+ * Component mapping: the primary subtag becomes the language, the region
+ * subtag (2 alpha or 3 digits) becomes the country, the script subtag (4
+ * alpha) is upper-cased and mapped to the variant, any other variant
+ * subtags are appended to the variant unchanged (variant values are case
+ * sensitive in Locale) with '_' as separator, and extension/private use
+ * subtags are dropped.
+ *
+ * @param tag The language tag
+ *
+ * @return The Locale for the tag, or {@link Locale#ROOT} if the tag is
+ * syntactically invalid
+ */
+ @SuppressWarnings("deprecation")
+ static Locale toLegacyLocale(String tag) {
+ int len = tag.length();
+ if (len == 0) {
+ return Locale.ROOT;
+ }
+
+ // Primary subtag: 2 to 8 ASCII letters, case insensitive
+ int pos = 0;
+ int end = tag.indexOf('-', 0);
+ if (end < 0) {
+ end = len;
+ }
+ int primaryLen = end;
+ if (primaryLen < 2 || primaryLen > 8 || !allAsciiAlpha(tag, 0, end)) {
+ return Locale.ROOT;
+ }
+ String language = lower(tag, 0, end);
+
+ String country = "";
+ StringBuilder variant = new StringBuilder();
+ boolean scriptSeen = false;
+ boolean countrySeen = false;
+
+ pos = end;
+ while (pos < len) {
+ // Skip the '-'
+ pos++;
+ end = tag.indexOf('-', pos);
+ if (end < 0) {
+ end = len;
+ }
+ int subLen = end - pos;
+ if (subLen == 0) {
+ // Empty subtag, malformed, drop the remainder
+ break;
+ }
+
+ if (subLen == 2 && !countrySeen && allAsciiAlpha(tag, pos, end)) {
+ // Region subtag (alpha-2)
+ country = upper(tag, pos, end);
+ countrySeen = true;
+ } else if (subLen == 3 && !countrySeen && allAsciiDigits(tag, pos,
end)) {
+ // Region subtag (numeric-3)
+ country = tag.substring(pos, end);
+ countrySeen = true;
+ } else if (subLen == 4 && !scriptSeen && !countrySeen &&
allAsciiAlpha(tag, pos, end)) {
+ // Script subtag. The deprecated constructor has no script
+ // component so it is mapped to the variant instead. The
+ // subtag is upper-cased to clearly distinguish it from
+ // regular variant subtags which are kept unchanged.
+ appendVariant(variant, upper(tag, pos, end));
+ scriptSeen = true;
+ } else if (isVariantSubtag(tag, pos, end, subLen)) {
+ // Variant subtag. Locale variants are case sensitive and
+ // Locale.forLanguageTag() does not change their case, so
+ // this subtag is used unchanged.
+ appendVariant(variant, tag.substring(pos, end));
+ } else if (subLen == 1) {
+ // Singleton (extension or private use sequence), drop the
+ // remainder
+ break;
+ } else {
+ // Unexpected subtag, drop the remainder
+ break;
+ }
+
+ pos = end;
+ }
+
+ return new Locale(language, country, variant.toString());
+ }
+
+
+ private static void appendVariant(StringBuilder variant, String subtag) {
+ // Bound the variant size. The header size limit already bounds this
+ // indirectly but be explicit.
+ if (variant.length() + subtag.length() + 1 <= 64) {
+ if (variant.length() > 0) {
+ variant.append('_');
+ }
+ variant.append(subtag);
+ }
+ }
+
+
+ /*
+ * BCP 47 variant subtag: 5 to 8 alphanumeric characters, or 4 characters
+ * where the first is a digit and the rest are alphanumeric.
+ */
+ private static boolean isVariantSubtag(String tag, int start, int end, int
subLen) {
+ if (subLen >= 5 && subLen <= 8) {
+ return allAsciiAlnum(tag, start, end);
+ }
+ if (subLen == 4) {
+ char first = tag.charAt(start);
+ if (first < '0' || first > '9') {
+ return false;
+ }
+ return allAsciiAlnum(tag, start + 1, end);
+ }
+ return false;
+ }
+
+
+ private static String lower(String tag, int start, int end) {
+ StringBuilder sb = new StringBuilder(end - start);
+ for (int i = start; i < end; i++) {
+ char c = tag.charAt(i);
+ sb.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c);
+ }
+ return sb.toString();
+ }
+
+
+ private static String upper(String tag, int start, int end) {
+ StringBuilder sb = new StringBuilder(end - start);
+ for (int i = start; i < end; i++) {
+ char c = tag.charAt(i);
+ sb.append(c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c);
+ }
+ return sb.toString();
+ }
+
+
+ private static boolean allAsciiAlpha(String tag, int start, int end) {
+ for (int i = start; i < end; i++) {
+ char c = tag.charAt(i);
+ if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z')) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
+ private static boolean allAsciiDigits(String tag, int start, int end) {
+ for (int i = start; i < end; i++) {
+ char c = tag.charAt(i);
+ if (c < '0' || c > '9') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
+ private static boolean allAsciiAlnum(String tag, int start, int end) {
+ for (int i = start; i < end; i++) {
+ char c = tag.charAt(i);
+ if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c
> '9')) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/java/org/apache/tomcat/util/res/StringManager.java
b/java/org/apache/tomcat/util/res/StringManager.java
index a893d629c2..7724748ee2 100644
--- a/java/org/apache/tomcat/util/res/StringManager.java
+++ b/java/org/apache/tomcat/util/res/StringManager.java
@@ -45,6 +45,9 @@ public class StringManager {
private static final int LOCALE_CACHE_SIZE = 10;
+ private static final ResourceBundle.Control BUNDLE_CONTROL =
+
ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
+
/**
* The ResourceBundle for this StringManager.
*/
@@ -255,12 +258,75 @@ public class StringManager {
public static StringManager getManager(String packageName,
Enumeration<Locale> requestedLocales) {
while (requestedLocales.hasMoreElements()) {
Locale locale = requestedLocales.nextElement();
+ // Only look up locales that can actually match.
+ if (!hasBundle(packageName, locale)) {
+ continue;
+ }
StringManager result = getManager(packageName, locale);
- if (result.getLocale().equals(locale)) {
+ if (result.getLocale() != null &&
result.getLocale().equals(locale)) {
return result;
}
}
// Return the default
return getManager(packageName);
}
+
+
+ /**
+ * Determines if a requested locale can ever match. Only locales for which
an exact match bundle exists can
+ * match so only those should be passed to the constructor.
+ *
+ * @param packageName The package name
+ * @param locale The requested locale
+ *
+ * @return true if the locale should be looked up
+ */
+ private static boolean hasBundle(String packageName, Locale locale) {
+ String bundleName = packageName + ".LocalStrings";
+ // The constructor coerces all English locales to Locale.ROOT and the
+ // root bundle is reported as Locale.ENGLISH so only a plain "en"
+ // request can match. Locale.ROOT is a single, fixed cache key so it
+ // is probed rather than every en-* variant.
+ if (Locale.ENGLISH.getLanguage().equals(locale.getLanguage())) {
+ return locale.equals(Locale.ENGLISH) && bundleExists(bundleName,
Locale.ROOT);
+ }
+ return bundleExists(bundleName, locale);
+ }
+
+
+ /**
+ * Checks if a bundle exists for the given locale without adding an entry
to the JVM-wide ResourceBundle cache.
+ *
+ * @param bundleName The full bundle name
+ * @param locale The locale
+ *
+ * @return true if a bundle exists for the given locale
+ */
+ private static boolean bundleExists(String bundleName, Locale locale) {
+ ClassLoader classLoader = StringManager.class.getClassLoader();
+ if (bundleExists(bundleName, locale, classLoader)) {
+ return true;
+ }
+ // The constructor also falls back to the current thread context
+ // class loader so probe it too.
+ ClassLoader contextClassLoader =
Thread.currentThread().getContextClassLoader();
+ if (contextClassLoader != null && contextClassLoader != classLoader) {
+ return bundleExists(bundleName, locale, contextClassLoader);
+ }
+ return false;
+ }
+
+
+ private static boolean bundleExists(String bundleName, Locale locale,
ClassLoader classLoader) {
+ try {
+ // Control.newBundle() loads the bundle (or returns null) without
+ // caching the result in the JVM-wide ResourceBundle cache.
+ return BUNDLE_CONTROL.newBundle(bundleName, locale,
+ ResourceBundle.Control.FORMAT_PROPERTIES.get(0),
classLoader, false) != null;
+ } catch (Exception ignore) {
+ // Any load failure is treated as "no bundle" which matches the
+ // behaviour of the constructor.
+ return false;
+ }
+ }
}
diff --git a/test/org/apache/catalina/connector/TestRequest.java
b/test/org/apache/catalina/connector/TestRequest.java
index 30ff9f1252..6898acfd34 100644
--- a/test/org/apache/catalina/connector/TestRequest.java
+++ b/test/org/apache/catalina/connector/TestRequest.java
@@ -790,6 +790,144 @@ public class TestRequest extends TomcatBaseTest {
}
}
+ /*
+ * The number of locales in the Accept-Language header(s) is
+ * limited because the header value is untrusted input. Requests
+ * that exceed the limit are rejected (when the locales are
+ * accessed) in the same way as requests that exceed
+ * maxCookieCount.
+ */
+ @Test
+ public void testAcceptLanguageLocaleLimit() throws Exception {
+ TesterRequest req = new TesterRequest();
+
+ req.addHeader("accept-language", buildAcceptLanguage(20));
+
+ try {
+ req.getLocales();
+ Assert.fail("Accessing the locales when the limit is exceeded
should throw an IllegalArgumentException");
+ } catch (IllegalArgumentException iae) {
+ // Expected
+ }
+
+ // Requests within the limit are processed and the highest
+ // quality locale (at the start of the list) is retained
+ req = new TesterRequest();
+ req.addHeader("accept-language", "aa-000,aa-001,aa-002");
+ Assert.assertEquals(Locale.forLanguageTag("aa-000"), req.getLocale());
+ }
+
+ /*
+ * The locale limit is configurable via the maxLocaleCount attribute
+ * of the Connector associated with the request.
+ */
+ @Test
+ public void testAcceptLanguageLocaleLimitConfigurable() throws Exception {
+ // Connector default of 10 permits exactly 10 locales
+ Assert.assertEquals(10, localeCount(new Connector(),
buildAcceptLanguage(10)));
+
+ // Explicit limit is enforced
+ Connector connector = new Connector();
+ connector.setMaxLocaleCount(5);
+ Assert.assertEquals(5, localeCount(connector, buildAcceptLanguage(5)));
+
+ try {
+ localeCount(connector, buildAcceptLanguage(6));
+ Assert.fail("Exceeding maxLocaleCount should throw an
IllegalArgumentException");
+ } catch (IllegalArgumentException iae) {
+ // Expected
+ }
+
+ // No limit
+ connector = new Connector();
+ connector.setMaxLocaleCount(-1);
+ Assert.assertEquals(20, localeCount(connector,
buildAcceptLanguage(20)));
+ }
+
+ private static String buildAcceptLanguage(int localeCount) {
+ StringBuilder acceptLanguage = new StringBuilder();
+ for (int i = 0; i < localeCount; i++) {
+ if (i > 0) {
+ acceptLanguage.append(',');
+ }
+ acceptLanguage.append(String.format("aa-%03d", i));
+ }
+ return acceptLanguage.toString();
+ }
+
+ private static int localeCount(Connector connector, String acceptLanguage)
{
+ org.apache.coyote.Request coyoteRequest = new
org.apache.coyote.Request();
+
coyoteRequest.getMimeHeaders().addValue("accept-language").setString(acceptLanguage);
+ Request request = new Request(connector, coyoteRequest);
+
+ Enumeration<Locale> locales = request.getLocales();
+ int count = 0;
+ while (locales.hasMoreElements()) {
+ locales.nextElement();
+ count++;
+ }
+ return count;
+ }
+
+ /*
+ * The limit on untrusted locale tags is enforced while the
+ * header is parsed so that Locale objects are not created for
+ * entries that can never be used.
+ */
+ @Test
+ public void testAcceptLanguageLocaleLimitHeaderFlood() throws Exception {
+ Connector connector = new Connector();
+
+ // Default limit of 10. A header with far more tags than the
+ // limit is rejected.
+ try {
+ localeCount(connector, buildAcceptLanguage(5000));
+ Assert.fail("A header with 5000 locales should be rejected");
+ } catch (IllegalArgumentException iae) {
+ // Expected
+ }
+
+ // Exactly the limit is accepted.
+ Assert.assertEquals(10, localeCount(connector,
buildAcceptLanguage(10)));
+ }
+
+ /*
+ * When multiple Accept-Language headers are present the limit
+ * applies to the combined set of locales and parsing stops
+ * early on the header in which the limit is exceeded.
+ */
+ @Test
+ public void testAcceptLanguageLocaleLimitMultipleHeaders() throws
Exception {
+ // 5 + 5 = 10, within the default limit
+ Assert.assertEquals(10, localeCount(new Connector(),
"aa-000,aa-001,aa-002,aa-003,aa-004",
+ "aa-005,aa-006,aa-007,aa-008,aa-009"));
+
+ // 5 + 6 = 11, exceeds the default limit
+ try {
+ localeCount(new Connector(), "aa-000,aa-001,aa-002,aa-003,aa-004",
+ "aa-005,aa-006,aa-007,aa-008,aa-009,aa-010");
+ Assert.fail("11 locales across two headers should be rejected");
+ } catch (IllegalArgumentException iae) {
+ // Expected
+ }
+ }
+
+ private static int localeCount(Connector connector, String...
acceptLanguages) {
+ org.apache.coyote.Request coyoteRequest = new
org.apache.coyote.Request();
+ for (String acceptLanguage : acceptLanguages) {
+
coyoteRequest.getMimeHeaders().addValue("accept-language").setString(acceptLanguage);
+ }
+ Request request = new Request(connector, coyoteRequest);
+
+ Enumeration<Locale> locales = request.getLocales();
+ int count = 0;
+ while (locales.hasMoreElements()) {
+ locales.nextElement();
+ count++;
+ }
+ return count;
+ }
+
@Test
public void getLocaleMultipleHeaders01() throws Exception {
TesterRequest req = new TesterRequest();
diff --git a/test/org/apache/catalina/valves/TestErrorReportValve.java
b/test/org/apache/catalina/valves/TestErrorReportValve.java
index b3c3b1d207..c3e0ec6f3e 100644
--- a/test/org/apache/catalina/valves/TestErrorReportValve.java
+++ b/test/org/apache/catalina/valves/TestErrorReportValve.java
@@ -17,7 +17,13 @@
package org.apache.catalina.valves;
import java.io.IOException;
+import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.RequestDispatcher;
@@ -263,4 +269,58 @@ public class TestErrorReportValve extends TomcatBaseTest {
}
+ @Test
+ public void testAcceptLanguageResourceBundleCacheUsage() throws Exception {
+ Tomcat tomcat = getTomcatInstance();
+
+ // No file system docBase required
+ getProgrammaticRootContext();
+
+ tomcat.start();
+
+ int initial = resourceBundleCacheSize();
+
+ final int requests = 20;
+ final int localesPerRequest = 20;
+ for (int i = 0; i < requests; i++) {
+ StringBuilder acceptLanguage = new StringBuilder();
+ for (int j = 0; j < localesPerRequest; j++) {
+ if (acceptLanguage.length() > 0) {
+ acceptLanguage.append(',');
+ }
+ acceptLanguage.append(fakeLanguageTag(i * localesPerRequest +
j));
+ }
+ Map<String,List<String>> reqHead = new HashMap<>();
+ reqHead.put("Accept-Language",
Collections.singletonList(acceptLanguage.toString()));
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("http://localhost:" + getPort() +
"/does-not-exist-" + i, res, reqHead, null);
+ Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+ }
+
+ int delta = resourceBundleCacheSize() - initial;
+ Assert.assertTrue("ResourceBundle cache grew by " + delta + " entries
for " +
+ (requests * localesPerRequest) + " unknown request locales",
delta < 50);
+ }
+
+
+ private static String fakeLanguageTag(int i) {
+ StringBuilder tag = new StringBuilder("xxxxx");
+ int v = i;
+ for (int k = 4; k >= 0; k--) {
+ tag.setCharAt(k, (char) ('a' + v % 26));
+ v /= 26;
+ }
+ return tag.toString();
+ }
+
+
+ @SuppressWarnings("unchecked")
+ private static int resourceBundleCacheSize() throws Exception {
+ Field field = ResourceBundle.class.getDeclaredField("cacheList");
+ field.setAccessible(true);
+ Map<Object,Object> cache = (Map<Object,Object>) field.get(null);
+ return cache.size();
+ }
+
+
}
diff --git a/test/org/apache/tomcat/util/http/parser/TestAcceptLanguage.java
b/test/org/apache/tomcat/util/http/parser/TestAcceptLanguage.java
index 93df636dfb..86dcc093de 100644
--- a/test/org/apache/tomcat/util/http/parser/TestAcceptLanguage.java
+++ b/test/org/apache/tomcat/util/http/parser/TestAcceptLanguage.java
@@ -355,4 +355,65 @@ public class TestAcceptLanguage {
Assert.assertEquals(l2, actual.get(1).getLocale());
Assert.assertEquals(Q0_050, actual.get(1).getQuality(), 0.0001);
}
+
+
+ /*
+ * The parse(Reader, int) overload stops as soon as the
+ * requested number of entries has been read.
+ */
+ @Test
+ public void testLimited01() throws Exception {
+ List<AcceptLanguage> actual =
+ AcceptLanguage.parse(new StringReader("en,fr,de"), 2);
+
+ Assert.assertEquals(2, actual.size());
+ Assert.assertEquals(L_EN, actual.get(0).getLocale());
+ Assert.assertEquals(L_FR, actual.get(1).getLocale());
+ }
+
+
+ /*
+ * A limit of zero means no entries are parsed.
+ */
+ @Test
+ public void testLimited02() throws Exception {
+ List<AcceptLanguage> actual =
+ AcceptLanguage.parse(new StringReader("en,fr,de"), 0);
+
+ Assert.assertEquals(0, actual.size());
+ }
+
+
+ /*
+ * A limit of -1 means no limit.
+ */
+ @Test
+ public void testLimited03() throws Exception {
+ StringBuilder header = new StringBuilder();
+ for (int i = 0; i < 100; i++) {
+ if (i > 0) {
+ header.append(',');
+ }
+ header.append("aa-").append(String.format("%03d", i));
+ }
+
+ Assert.assertEquals(100, AcceptLanguage.parse(new
StringReader(header.toString()), -1).size());
+ }
+
+
+ /*
+ * Entries beyond the limit are not parsed at all. The list
+ * below the limit is valid. An entry with a quality of zero
+ * is not added, so it does not consume part of the limit.
+ */
+ @Test
+ public void testLimited04() throws Exception {
+ List<AcceptLanguage> actual =
+ AcceptLanguage.parse(new StringReader("en,fr,de;q=0,de,de"),
3);
+
+ Assert.assertEquals(3, actual.size());
+ Assert.assertEquals(L_EN, actual.get(0).getLocale());
+ Assert.assertEquals(L_FR, actual.get(1).getLocale());
+ Assert.assertEquals(Locale.forLanguageTag("de"),
actual.get(2).getLocale());
+ }
}
diff --git a/test/org/apache/tomcat/util/res/TestStringManager.java
b/test/org/apache/tomcat/util/res/TestStringManager.java
index 289d3973df..c698dfc941 100644
--- a/test/org/apache/tomcat/util/res/TestStringManager.java
+++ b/test/org/apache/tomcat/util/res/TestStringManager.java
@@ -17,9 +17,16 @@
package org.apache.tomcat.util.res;
import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.HashSet;
+import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Properties;
+import java.util.ResourceBundle;
import java.util.Set;
import org.junit.Assert;
@@ -148,4 +155,68 @@ public class TestStringManager {
return s.length();
}
}
+
+
+ @Test
+ public void testGetManagerWithLocales() {
+ List<Locale> locales = Arrays.asList(Locale.FRENCH, Locale.GERMAN);
+ StringManager sm = StringManager.getManager(PACKAGE_NAME,
Collections.enumeration(locales));
+ Assert.assertEquals(Locale.FRENCH, sm.getLocale());
+
+ locales = Arrays.asList(Locale.GERMAN, Locale.FRENCH);
+ sm = StringManager.getManager(PACKAGE_NAME,
Collections.enumeration(locales));
+ Assert.assertEquals(Locale.GERMAN, sm.getLocale());
+ }
+
+
+ @Test
+ public void testGetManagerWithLocalesSkipsMissingBundles() {
+ // Locales without an exact match bundle must be skipped
+ List<Locale> locales = Arrays.asList(Locale.forLanguageTag("xxxxx"),
Locale.FRENCH);
+ StringManager sm = StringManager.getManager(PACKAGE_NAME,
Collections.enumeration(locales));
+ Assert.assertEquals(Locale.FRENCH, sm.getLocale());
+
+ // No exact match at all - the default is returned
+ locales = Collections.singletonList(Locale.forLanguageTag("xxxxx"));
+ sm = StringManager.getManager(PACKAGE_NAME,
Collections.enumeration(locales));
+
Assert.assertEquals(StringManager.getManager(PACKAGE_NAME).getLocale(),
sm.getLocale());
+ }
+
+
+ @Test
+ public void testGetManagerWithLocalesResourceBundleCacheUsage() throws
Exception {
+ int initial = resourceBundleCacheSize();
+
+ for (int i = 0; i < 5; i++) {
+ List<Locale> locales = new ArrayList<>();
+ for (int j = 0; j < 1000; j++) {
+ locales.add(fakeLocale(i * 1000 + j));
+ }
+ StringManager.getManager(PACKAGE_NAME,
Collections.enumeration(locales));
+ }
+
+ int delta = resourceBundleCacheSize() - initial;
+ Assert.assertTrue("ResourceBundle cache grew by " + delta + " entries
for 5000 unknown locales",
+ delta < 100);
+ }
+
+
+ private static Locale fakeLocale(int i) {
+ StringBuilder language = new StringBuilder("xxxxx");
+ int v = i;
+ for (int k = 4; k >= 0; k--) {
+ language.setCharAt(k, (char) ('a' + v % 26));
+ v /= 26;
+ }
+ return Locale.forLanguageTag(language.toString());
+ }
+
+
+ @SuppressWarnings("unchecked")
+ private static int resourceBundleCacheSize() throws Exception {
+ Field field = ResourceBundle.class.getDeclaredField("cacheList");
+ field.setAccessible(true);
+ Map<Object,Object> cache = (Map<Object,Object>) field.get(null);
+ return cache.size();
+ }
}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 4e3a5e2539..b130e1a029 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -297,6 +297,10 @@
the JNDI realm binds using the user provided name and password to
authenticate the user. (markt)
</fix>
+ <update>
+ Optimize <code>Accept-Language</code> header processing and use of
+ resource budles for localization. (remm)
+ </update>
</changelog>
</subsection>
<subsection name="Coyote">
diff --git a/webapps/docs/config/ajp.xml b/webapps/docs/config/ajp.xml
index e4df3fd4ce..00d2f20adf 100644
--- a/webapps/docs/config/ajp.xml
+++ b/webapps/docs/config/ajp.xml
@@ -173,6 +173,14 @@
will be used.</p>
</attribute>
+ <attribute name="maxLocaleCount" required="false">
+ <p>The maximum number of locales (obtained from the
+ <code>Accept-Language</code> header(s)) that are permitted for a request.
+ Requests that exceed this limit will be rejected when the locales are
+ parsed. A value of less than 0 means no limit. If not specified, a
+ default of 10 is used.</p>
+ </attribute>
+
<attribute name="maxParameterCount" required="false">
<p>The maximum total number of request parameters (including uploaded
files) obtained from the query string and, for POST requests, the request
diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml
index d22e33c15b..2b50884c70 100644
--- a/webapps/docs/config/http.xml
+++ b/webapps/docs/config/http.xml
@@ -169,6 +169,14 @@
will be used.</p>
</attribute>
+ <attribute name="maxLocaleCount" required="false">
+ <p>The maximum number of locales (obtained from the
+ <code>Accept-Language</code> header(s)) that are permitted for a request.
+ Requests that exceed this limit will be rejected when the locales are
+ parsed. A value of less than 0 means no limit. If not specified, a
+ default of 10 is used.</p>
+ </attribute>
+
<attribute name="maxParameterCount" required="false">
<p>The maximum total number of request parameters (including uploaded
files) obtained from the query string and, for POST requests, the request
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]