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]

Reply via email to