diff --git a/user/src/com/google/gwt/i18n/server/GwtLocaleFactoryImpl.java b/user/src/com/google/gwt/i18n/server/GwtLocaleFactoryImpl.java index 3af2f5a6d25..af14f7bae60 100644 --- a/user/src/com/google/gwt/i18n/server/GwtLocaleFactoryImpl.java +++ b/user/src/com/google/gwt/i18n/server/GwtLocaleFactoryImpl.java @@ -36,6 +36,24 @@ private static boolean isDigit(String str, int min, int max) { return matches(str, min, max, false); } + private static boolean isAlphaNumeric(String str, int min, int max) { + int len = str.length(); + if (len < min || len > max) { + return false; + } + for (int i = 0; i < len; ++i) { + char c = str.charAt(i); + // BCP47 subtags are ASCII only, so avoid Character.isLetterOrDigit which + // would also accept non-ASCII letters and digits. + boolean alphaNumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9'); + if (!alphaNumeric) { + return false; + } + } + return true; + } + /** * Check if the supplied string matches length and composition requirements. * @@ -143,6 +161,14 @@ public GwtLocale fromString(String localeName) { ArrayList localeParts = new ArrayList(); String[] parts = localeName.split("[-_]"); for (int i = 0; i < parts.length; ++i) { + // The split only breaks on '-'/'_', so any other character stays inside + // a part. Require each subtag to be alphanumeric and 1-8 chars, matching + // BCP47, so an untrusted server-side locale can't smuggle '.', '$' or + // '/' into the class name LocalizableInstantiator resolves reflectively. + if (!isAlphaNumeric(parts[i], 1, 8)) { + throw new IllegalArgumentException("Unrecognized locale format: " + + localeName); + } if (parts[i].length() == 1 && i + 1 < parts.length) { localeParts.add(parts[i] + '-' + parts[++i]); } else { diff --git a/user/test/com/google/gwt/i18n/server/GwtLocaleTest.java b/user/test/com/google/gwt/i18n/server/GwtLocaleTest.java index 6b3e101ff8d..e7e98f75d5c 100644 --- a/user/test/com/google/gwt/i18n/server/GwtLocaleTest.java +++ b/user/test/com/google/gwt/i18n/server/GwtLocaleTest.java @@ -15,6 +15,8 @@ */ package com.google.gwt.i18n.server; +import static com.google.gwt.testing.server.Assertions.assertThrows; + import com.google.gwt.i18n.shared.GwtLocale; import com.google.gwt.i18n.shared.GwtLocaleFactory; @@ -174,6 +176,26 @@ public void testFromString() { } } + public void testFromStringRejectsInvalidLanguage() { + // The language subtag is concatenated into class names resolved reflectively + // on the server, so it must not carry characters like '.', '$' or '/'. + String[] invalid = { + "com.google.gwt.dev.Compiler", "java.lang.Runtime", "x$Evil", "a/b/c", + "en.US", + }; + for (String locale : invalid) { + assertThrows(IllegalArgumentException.class, + () -> factory.fromString(locale), + "Should have thrown IllegalArgumentException on " + locale); + } + // Well-formed tags, including extended-language and private-use forms, are + // still accepted with the language preserved verbatim. + assertEquals("en", factory.fromString("en_US").getLanguage()); + assertEquals("zh-cmn", factory.fromString("zh-cmn").getLanguage()); + assertEquals("i-klingon", factory.fromString("i-klingon").getLanguage()); + assertEquals("x-foo123", factory.fromString("x-foo123").getLanguage()); + } + public void testInheritance() { GwtLocale en = factory.fromString("en_Latn_US_VARIANT"); List chain = en.getInheritanceChain(); diff --git a/user/test/com/google/gwt/testing/TestUtils.gwt.xml b/user/test/com/google/gwt/testing/TestUtils.gwt.xml index d485f79fbe8..1ebd70e2653 100644 --- a/user/test/com/google/gwt/testing/TestUtils.gwt.xml +++ b/user/test/com/google/gwt/testing/TestUtils.gwt.xml @@ -13,5 +13,9 @@ - + + + + diff --git a/user/test/com/google/gwt/testing/server/Assertions.java b/user/test/com/google/gwt/testing/server/Assertions.java new file mode 100644 index 00000000000..f70d1c8dc3a --- /dev/null +++ b/user/test/com/google/gwt/testing/server/Assertions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 GWT Project Authors + * + * Licensed 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 com.google.gwt.testing.server; + +/** + * Assertion helpers for server-side JUnit 3 tests, where the JUnit 4/5 + * {@code assertThrows} is not available. + */ +public class Assertions { + + /** + * A block of code that may throw any {@link Throwable}, so it can be passed to + * {@link #assertThrows} as a lambda. + */ + public interface Executable { + void execute() throws Throwable; + } + + /** + * Asserts that running {@code executable} throws an exception of + * {@code expectedType} (or a subtype) and returns it, failing with + * {@code message} if nothing is thrown or a different type is thrown. + */ + public static T assertThrows(Class expectedType, + Executable executable, String message) { + try { + executable.execute(); + } catch (Throwable actual) { + if (expectedType.isInstance(actual)) { + return expectedType.cast(actual); + } + throw new AssertionError(message + " but threw " + actual, actual); + } + throw new AssertionError(message); + } + + private Assertions() { + } +}