You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by th...@apache.org on 2026/10/17 13:17:43 UTC
(tapestry-5) 03/10: LocalizationSetter improvements
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch 5.9.x-javax
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
commit d3eb5337fb2f6123509355452397e5834ccc6d82
Author: Ben Weidig <be...@netzgut.net>
AuthorDate: Wed Oct 7 08:21:08 2026 +0200
LocalizationSetter improvements
---
.../internal/services/LocalizationSetterImpl.java | 82 ++++++++++++++-
.../services/LocalizationSetterImplTest.java | 115 ++++++++++++++++++++-
2 files changed, 191 insertions(+), 6 deletions(-)
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/LocalizationSetterImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/LocalizationSetterImpl.java
index b2962ca57..e86069348 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/LocalizationSetterImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/LocalizationSetterImpl.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2007, 2009, 2010, 2012 The Apache Software Foundation
+// Copyright 2006, 2007, 2009, 2010, 2012, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -39,6 +39,23 @@ import org.apache.tapestry5.services.PersistentLocale;
*/
public class LocalizationSetterImpl implements LocalizationSetter
{
+ /**
+ * Upper bound on the number of entries retained in {@link #localeCache}.
+ *
+ * <p>
+ * The cache lives for the lifetime of this (singleton) service.
+ * The configured supported locales are pre-seeded and never evicted, so this budget only applies
+ * to request-derived names.
+ * </p>
+ */
+ static final int MAX_LOCALE_CACHE_SIZE = 1000;
+
+ /**
+ * Maximum length of a single underscore-separated term (language, country or variant) of a
+ * cacheable locale name.
+ */
+ static final int MAX_TERM_LENGTH = 16;
+
private final Request request;
private final ThreadLocale threadLocale;
@@ -87,7 +104,13 @@ public class LocalizationSetterImpl implements LocalizationSetter
for (String name : localeNames)
{
- list.add(toLocale(name));
+ Locale locale = constructLocale(name);
+
+ // Pre-seed the cache with the supported locales.
+ // They are trusted configuration and the hot resolution target.
+ localeCache.put(name, locale);
+
+ list.add(locale);
}
return Collections.unmodifiableList(list);
@@ -100,12 +123,63 @@ public class LocalizationSetterImpl implements LocalizationSetter
if (result == null)
{
result = constructLocale(localeName);
- localeCache.put(localeName, result);
+
+ if (isCacheableLocaleName(localeName) && localeCache.size() < MAX_LOCALE_CACHE_SIZE)
+ {
+ localeCache.put(localeName, result);
+ }
}
return result;
}
+ /**
+ * Whether a locale name is structurally plausible enough to be worth storing in the long-lived
+ * {@link #localeCache}.
+ *
+ * <p>This is a deliberately strict, cheap guard, instead of a full full locale validation:
+ * at most three underscore-separated terms, each ASCII-alphanumeric and bounded by
+ * {@link #MAX_TERM_LENGTH}.
+ */
+ static boolean isCacheableLocaleName(String localeName)
+ {
+ final int length = localeName.length();
+
+ if (length == 0)
+ {
+ return false;
+ }
+
+ int terms = 1;
+ int termLength = 0;
+
+ for (int i = 0; i < length; i++)
+ {
+ final char c = localeName.charAt(i);
+
+ if (c == '_')
+ {
+ // matches constructLocale()'s own limit of three terms
+ if (++terms > 3)
+ {
+ return false;
+ }
+ termLength = 0;
+ }
+ else if (!isAsciiLetterOrDigit(c) || ++termLength > MAX_TERM_LENGTH)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static boolean isAsciiLetterOrDigit(char c)
+ {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
+ }
+
private Locale constructLocale(String name)
{
String[] terms = name.split("_");
@@ -119,11 +193,9 @@ public class LocalizationSetterImpl implements LocalizationSetter
return new Locale(terms[0], terms[1]);
case 3:
-
return new Locale(terms[0], terms[1], terms[2]);
default:
-
throw new IllegalArgumentException();
}
}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/LocalizationSetterImplTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/LocalizationSetterImplTest.java
index 8aa409b6d..0c1e69138 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/LocalizationSetterImplTest.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/LocalizationSetterImplTest.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2009, 2010, 2012 The Apache Software Foundation
+// Copyright 2006, 2009, 2010, 2012, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ import static org.apache.tapestry5.commons.util.CollectionFactory.newSet;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import org.apache.tapestry5.OptionModel;
import org.apache.tapestry5.SelectModel;
@@ -71,6 +72,118 @@ public class LocalizationSetterImplTest extends InternalBaseTestCase
checkLocale(setter.toLocale("klingon_Gach_snuff"), "klingon", "GACH", "snuff");
}
+ @SuppressWarnings("unchecked")
+ private static Map<String, Locale> localeCache(LocalizationSetterImpl setter)
+ {
+ return (Map<String, Locale>) TestBase.get(setter, "localeCache");
+ }
+
+ @Test
+ public void is_cacheable_locale_name()
+ {
+ // well-formed names are cacheable
+ assertTrue(LocalizationSetterImpl.isCacheableLocaleName("en"));
+ assertTrue(LocalizationSetterImpl.isCacheableLocaleName("en_US"));
+ assertTrue(LocalizationSetterImpl.isCacheableLocaleName("en_US_POSIX"));
+ assertTrue(LocalizationSetterImpl.isCacheableLocaleName("es_419")); // numeric UN M.49 region
+ assertTrue(LocalizationSetterImpl.isCacheableLocaleName("klingon_Gach_snuff"));
+
+ // malformed / hostile names are not
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName(""));
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName("en_US_en_US")); // more than three terms
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName("en-US")); // non-alphanumeric separator
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName("en/../secret")); // path-ish junk
+
+ // a single over-long term, bounded by MAX_TERM_LENGTH...
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName(
+ repeat('a', LocalizationSetterImpl.MAX_TERM_LENGTH + 1)));
+
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName(
+ "en_US_" + repeat('a', LocalizationSetterImpl.MAX_TERM_LENGTH + 1)));
+
+ // a genuinely huge string is rejected without being fully scanned
+ assertFalse(LocalizationSetterImpl.isCacheableLocaleName(repeat('a', 8192)));
+ }
+
+ @Test
+ public void supported_locales_are_seeded_into_the_cache()
+ {
+ LocalizationSetterImpl setter = new LocalizationSetterImpl(null, null, null, "en,fr");
+
+ Map<String, Locale> cache = localeCache(setter);
+
+ // pre-seeded at construction, before any request can be processed
+ assertTrue(cache.containsKey("en"));
+ assertTrue(cache.containsKey("fr"));
+ }
+
+ @Test
+ public void locale_cache_is_size_bounded()
+ {
+ LocalizationSetterImpl setter = new LocalizationSetterImpl(null, null, null, "en");
+
+ // Fill the cache with distinct, well-formed short names.
+ // The cache must stop growing at its cap rather than expanding without bound.
+ for (int i = 0; i < LocalizationSetterImpl.MAX_LOCALE_CACHE_SIZE + 500; i++)
+ {
+ setter.toLocale("zz" + i);
+ }
+
+ assertTrue(localeCache(setter).size() <= LocalizationSetterImpl.MAX_LOCALE_CACHE_SIZE);
+ }
+
+ @Test
+ public void overly_long_locale_names_are_not_cached()
+ {
+ LocalizationSetterImpl setter = new LocalizationSetterImpl(null, null, null, "en");
+
+ // A well-formed short name is still cached (same instance returned on the second call)...
+ assertSame(setter.toLocale("de"), setter.toLocale("de"));
+
+ // ...large name is resolved without being retained, so it is re-parsed and
+ // a fresh (non-cached) instance comes back each time.
+ String longName = repeat('a', 8192);
+
+ assertNotNull(setter.toLocale(longName));
+ assertNotSame(setter.toLocale(longName), setter.toLocale(longName));
+ assertFalse(localeCache(setter).containsKey(longName));
+ }
+
+ @Test
+ public void attacker_supplied_locale_name_is_not_retained()
+ {
+ PersistentLocale pl = mockPersistentLocale();
+ ThreadLocale tl = mockThreadLocale();
+ Request request = mockRequest();
+
+ // an unresolvable, over-long name narrows to the default (first supported) locale
+ tl.setLocale(Locale.ENGLISH);
+
+ replay();
+
+ LocalizationSetterImpl setter = new LocalizationSetterImpl(request, pl, tl, "en,fr");
+
+ String attackerName = repeat('x', 8192);
+
+ setter.setNonPersistentLocaleFromLocaleName(attackerName);
+
+ verify();
+
+ assertFalse(localeCache(setter).containsKey(attackerName));
+ }
+
+ private static String repeat(char c, int count)
+ {
+ StringBuilder builder = new StringBuilder(count);
+
+ for (int i = 0; i < count; i++)
+ {
+ builder.append(c);
+ }
+
+ return builder.toString();
+ }
+
@Test
public void known_locale()
{