This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 697b171d8 ClassUtils.getClass() name normalization (whitespace
stripping + dot-to-dollar rewriting) defeats host-side class-name string checks
(f020).
697b171d8 is described below
commit 697b171d8fe46d0cde62c973cf1b211e5003ccc4
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 14:43:08 2026 -0400
ClassUtils.getClass() name normalization (whitespace stripping +
dot-to-dollar rewriting) defeats host-side class-name string checks
(f020).
- ' java.lang.Runtime' loads java.lang.Runtime
- Add ClassUtils.getClassStrict(ClassLoader, String, boolean)
- Add ClassUtils.getClassStrict(String)
---
src/changes/changes.xml | 3 +
.../java/org/apache/commons/lang3/ClassUtils.java | 125 ++++++++++++++++++++-
.../org/apache/commons/lang3/ClassUtilsTest.java | 18 +++
3 files changed, 144 insertions(+), 2 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 82bc504eb..7d5882e15 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -269,6 +269,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">Fix LockingVisitors Javadoc to match code (f017).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">DoubleRange/Range accept NaN endpoints, silently producing a
half-unbounded range, contains()/fit() fail open for every value above the
minimum (f018).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">BitField long-mask fields silently mis-answer int/short/byte
accessors: shifts masked mod 32, and sign extension makes above-bit-31 flags
read as set on any negative holder (f019).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">ClassUtils.getClass() name normalization (whitespace stripping +
dot-to-dollar rewriting) defeats host-side class-name string checks
(f020).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
@@ -290,6 +291,8 @@ java.lang.NullPointerException: Cannot invoke
<action issue="LANG-1833" type="add" dev="ggregory" due-to="Maksym
Korshun">Handle ParsePosition index beyond source string length cleanly
(#1757).</action>
<action type="fix" dev="ggregory" due-to="alhudz, Gary
Gregory">Fix StrBuilder.lastIndexOf(String) start index for an empty search
(#1759).</action>
<action type="fix" dev="ggregory" due-to="alhudz, Gary
Gregory">Fix int overflow in Conversion count and position bounds guards
(#1779).</action>
+ <action type="add" dev="ggregory" due-to="Gary
Gregory">Add ClassUtils.getClassStrict(ClassLoader, String, boolean)
(f020).</action>
+ <action type="add" dev="ggregory" due-to="Gary
Gregory">Add ClassUtils.getClassStrict(String) (f020).</action>
<!-- UPDATE -->
<action type="update" dev="ggregory" due-to="Gary
Gregory, Dependabot">Bump org.apache.commons:commons-parent from 92 to 104
#1498.</action>
<action type="update" dev="ggregory" due-to="Gary
Gregory">[test] Bump org.apache.commons:commons-text from 1.14.0 to
1.15.0.</action>
diff --git a/src/main/java/org/apache/commons/lang3/ClassUtils.java
b/src/main/java/org/apache/commons/lang3/ClassUtils.java
index fd75e1732..302d6269c 100644
--- a/src/main/java/org/apache/commons/lang3/ClassUtils.java
+++ b/src/main/java/org/apache/commons/lang3/ClassUtils.java
@@ -552,6 +552,14 @@ private static String getCanonicalName(final String name) {
* The provided class name is normalized by removing all whitespace. This
is especially helpful when handling XML element values in which whitespace has
not
* been collapsed.
* </p>
+ * <p>
+ * <strong>Security note:</strong> because all whitespace is deleted
before the class is resolved (and a failing {@code '.'} may be retried as
{@code '$'}
+ * for inner classes), many distinct input strings resolve to the same
class, while {@link Class#forName(String)} performs no such normalization.
Validating
+ * an untrusted class name by string comparison <em>before</em> calling
this method is therefore unsound: for example, {@code " java.lang.Runtime"}
fails a
+ * naive {@code startsWith("java.")} denylist check on the raw string, yet
loads {@code java.lang.Runtime}. Validate the class name <em>after</em>
+ * normalization, validate the resolved {@link Class} object itself, or
use {@link #getClassStrict(ClassLoader, String, boolean)} which performs no
+ * whitespace normalization.
+ * </p>
*
* @param classLoader The class loader to use to load the class.
* @param className The class name.
@@ -577,6 +585,14 @@ public static Class<?> getClass(final ClassLoader
classLoader, final String clas
* The provided class name is normalized by removing all whitespace. This
is especially helpful when handling XML element values in which whitespace has
not
* been collapsed.
* </p>
+ * <p>
+ * <strong>Security note:</strong> because all whitespace is deleted
before the class is resolved (and a failing {@code '.'} may be retried as
{@code '$'}
+ * for inner classes), many distinct input strings resolve to the same
class, while {@link Class#forName(String)} performs no such normalization.
Validating
+ * an untrusted class name by string comparison <em>before</em> calling
this method is therefore unsound: for example, {@code " java.lang.Runtime"}
fails a
+ * naive {@code startsWith("java.")} denylist check on the raw string, yet
loads {@code java.lang.Runtime}. Validate the class name <em>after</em>
+ * normalization, validate the resolved {@link Class} object itself, or
use {@link #getClassStrict(ClassLoader, String, boolean)} which performs no
+ * whitespace normalization.
+ * </p>
*
* @param classLoader The class loader to use to load the class.
* @param className The class name.
@@ -592,13 +608,29 @@ public static Class<?> getClass(final ClassLoader
classLoader, final String clas
* @see <a
href="https://docs.oracle.com/javase/specs/jls/se25/html/jls-13.html#jls-13.1">JLS:
The Form of a Binary</a>
*/
public static Class<?> getClass(final ClassLoader classLoader, final
String className, final boolean initialize) throws ClassNotFoundException {
+ return getClass(classLoader, className, initialize, true);
+ }
+
+ /**
+ * Implements {@link #getClass(ClassLoader, String, boolean)} and {@link
#getClassStrict(ClassLoader, String, boolean)}.
+ *
+ * @param classLoader The class loader to use to load the class.
+ * @param className The class name.
+ * @param initialize whether the class must be initialized.
+ * @param normalizeWhitespace whether to delete all whitespace from the
class name before resolving it.
+ * @return The class represented by {@code className} using the {@code
classLoader}.
+ * @throws NullPointerException if the className is null.
+ * @throws ClassNotFoundException if the class is not found.
+ */
+ private static Class<?> getClass(final ClassLoader classLoader, final
String className, final boolean initialize, final boolean normalizeWhitespace)
+ throws ClassNotFoundException {
// This method was re-written to avoid recursion and stack overflows
found by fuzz testing.
String next = className;
int lastDotIndex = -1;
do {
try {
final Class<?> clazz = getPrimitiveClass(next);
- return clazz != null ? clazz :
Class.forName(toCleanName(next), initialize, classLoader);
+ return clazz != null ? clazz :
Class.forName(normalizeWhitespace ? toCleanName(next) : toEncodedName(next),
initialize, classLoader);
} catch (final ClassNotFoundException ex) {
lastDotIndex = next.lastIndexOf(PACKAGE_SEPARATOR_CHAR);
if (lastDotIndex != -1) {
@@ -617,6 +649,14 @@ public static Class<?> getClass(final ClassLoader
classLoader, final String clas
* The provided class name is normalized by removing all whitespace. This
is especially helpful when handling XML element values in which whitespace has
not
* been collapsed.
* </p>
+ * <p>
+ * <strong>Security note:</strong> because all whitespace is deleted
before the class is resolved (and a failing {@code '.'} may be retried as
{@code '$'}
+ * for inner classes), many distinct input strings resolve to the same
class, while {@link Class#forName(String)} performs no such normalization.
Validating
+ * an untrusted class name by string comparison <em>before</em> calling
this method is therefore unsound: for example, {@code " java.lang.Runtime"}
fails a
+ * naive {@code startsWith("java.")} denylist check on the raw string, yet
loads {@code java.lang.Runtime}. Validate the class name <em>after</em>
+ * normalization, validate the resolved {@link Class} object itself, or
use {@link #getClassStrict(ClassLoader, String, boolean)} which performs no
+ * whitespace normalization.
+ * </p>
*
* @param className The class name
* @return The class represented by {@code className} using the current
thread's context class loader
@@ -641,6 +681,14 @@ public static Class<?> getClass(final String className)
throws ClassNotFoundExce
* The provided class name is normalized by removing all whitespace. This
is especially helpful when handling XML element values in which whitespace has
not
* been collapsed.
* </p>
+ * <p>
+ * <strong>Security note:</strong> because all whitespace is deleted
before the class is resolved (and a failing {@code '.'} may be retried as
{@code '$'}
+ * for inner classes), many distinct input strings resolve to the same
class, while {@link Class#forName(String)} performs no such normalization.
Validating
+ * an untrusted class name by string comparison <em>before</em> calling
this method is therefore unsound: for example, {@code " java.lang.Runtime"}
fails a
+ * naive {@code startsWith("java.")} denylist check on the raw string, yet
loads {@code java.lang.Runtime}. Validate the class name <em>after</em>
+ * normalization, validate the resolved {@link Class} object itself, or
use {@link #getClassStrict(ClassLoader, String, boolean)} which performs no
+ * whitespace normalization.
+ * </p>
*
* @param className The class name.
* @param initialize whether the class must be initialized.
@@ -660,6 +708,66 @@ public static Class<?> getClass(final String className,
final boolean initialize
return getClass(loader, className, initialize);
}
+ /**
+ * Gets the class represented by {@code className} using the {@code
classLoader}, without normalizing the class name.
+ * <p>
+ * Unlike {@link #getClass(ClassLoader, String, boolean)}, this method
does <em>not</em> delete whitespace from the class name: a name that differs
from
+ * the intended binary name only by whitespace throws {@link
ClassNotFoundException}, matching {@link Class#forName(String, boolean,
ClassLoader)}. Use
+ * this variant when the class name may come from an untrusted source, so
that host-side string validation of the raw name cannot be bypassed through
+ * whitespace the loader would otherwise silently remove.
+ * </p>
+ * <p>
+ * The syntaxes "{@code java.util.Map.Entry[]}", "{@code
java.util.Map$Entry[]}", "{@code [Ljava.util.Map.Entry;}", and "{@code
[Ljava.util.Map$Entry;}"
+ * are still supported: a failing {@code '.'} is retried as {@code '$'} to
find inner classes, so more than one dotted spelling can still resolve to the
+ * same inner class. When validating untrusted names, prefer validating
the resolved {@link Class} object.
+ * </p>
+ *
+ * @param classLoader The class loader to use to load the class.
+ * @param className The class name.
+ * @param initialize whether the class must be initialized.
+ * @return The class represented by {@code className} using the {@code
classLoader}.
+ * @throws NullPointerException if the className is null.
+ * @throws ClassNotFoundException if the class is not found.
+ * @throws IllegalArgumentException Thrown if the class name represents an
array with more dimensions than the JVM supports, 255.
+ * @throws IllegalArgumentException Thrown if the class name length is
greater than 65,535.
+ * @see Class#forName(String, boolean, ClassLoader)
+ * @see #getClass(ClassLoader, String, boolean)
+ * @since 3.21.0
+ */
+ public static Class<?> getClassStrict(final ClassLoader classLoader, final
String className, final boolean initialize) throws ClassNotFoundException {
+ return getClass(classLoader, className, initialize, false);
+ }
+
+ /**
+ * Gets the (initialized) class represented by {@code className} using the
current thread's context class loader, without normalizing the class name.
+ * <p>
+ * Unlike {@link #getClass(String)}, this method does <em>not</em> delete
whitespace from the class name: a name that differs from the intended binary
+ * name only by whitespace throws {@link ClassNotFoundException}, matching
{@link Class#forName(String)}. Use this variant when the class name may come
+ * from an untrusted source, so that host-side string validation of the
raw name cannot be bypassed through whitespace the loader would otherwise
silently
+ * remove.
+ * </p>
+ * <p>
+ * The syntaxes "{@code java.util.Map.Entry[]}", "{@code
java.util.Map$Entry[]}", "{@code [Ljava.util.Map.Entry;}", and "{@code
[Ljava.util.Map$Entry;}"
+ * are still supported: a failing {@code '.'} is retried as {@code '$'} to
find inner classes, so more than one dotted spelling can still resolve to the
+ * same inner class. When validating untrusted names, prefer validating
the resolved {@link Class} object.
+ * </p>
+ *
+ * @param className The class name.
+ * @return The class represented by {@code className} using the current
thread's context class loader.
+ * @throws NullPointerException if the className is null.
+ * @throws ClassNotFoundException if the class is not found.
+ * @throws IllegalArgumentException Thrown if the class name represents an
array with more dimensions than the JVM supports, 255.
+ * @throws IllegalArgumentException Thrown if the class name length is
greater than 65,535.
+ * @see Class#forName(String)
+ * @see #getClass(String)
+ * @since 3.21.0
+ */
+ public static Class<?> getClassStrict(final String className) throws
ClassNotFoundException {
+ final ClassLoader contextCL =
Thread.currentThread().getContextClassLoader();
+ final ClassLoader loader = contextCL == null ?
ClassUtils.class.getClassLoader() : contextCL;
+ return getClassStrict(loader, className, true);
+ }
+
/**
* Delegates to {@link Class#getComponentType()} using generics.
*
@@ -1616,7 +1724,20 @@ public static Class<?>[] toClass(final Object... array) {
* @see <a
href="https://docs.oracle.com/javase/specs/jls/se25/html/jls-13.html#jls-13.1">JLS:
The Form of a Binary</a>
*/
private static String toCleanName(final String className) {
- String canonicalName = StringUtils.deleteWhitespace(className);
+ return toEncodedName(StringUtils.deleteWhitespace(className));
+ }
+
+ /**
+ * Converts a class name to a JLS style class name without normalizing
whitespace.
+ *
+ * @param className The class name.
+ * @return The converted name.
+ * @throws NullPointerException if the className is null.
+ * @throws IllegalArgumentException Thrown if the class name represents an
array with more dimensions than the JVM supports, 255.
+ * @throws IllegalArgumentException Thrown if the class name length is
greater than 65,535.
+ */
+ private static String toEncodedName(final String className) {
+ String canonicalName = className;
Objects.requireNonNull(canonicalName, "className");
if (canonicalName.isEmpty()) {
throw new IllegalArgumentException("Class name is empty");
diff --git a/src/test/java/org/apache/commons/lang3/ClassUtilsTest.java
b/src/test/java/org/apache/commons/lang3/ClassUtilsTest.java
index a630b327c..90c54fddd 100644
--- a/src/test/java/org/apache/commons/lang3/ClassUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/ClassUtilsTest.java
@@ -1276,6 +1276,24 @@ void testGetClassInner() throws ClassNotFoundException {
assertEquals(Inner.DeeplyNested.class,
ClassUtils.getClass(classLoader,
"org.apache.commons.lang3.ClassUtilsTest$Inner.DeeplyNested"));
}
+ @Test
+ void testGetClassStrict() throws Exception {
+ // Same resolution as getClass for exact binary names.
+ assertEquals(String.class,
ClassUtils.getClassStrict("java.lang.String"));
+ assertEquals(Inner.DeeplyNested.class,
ClassUtils.getClassStrict("org.apache.commons.lang3.ClassUtilsTest.Inner.DeeplyNested"));
+ final ClassLoader classLoader =
Inner.DeeplyNested.class.getClassLoader();
+ assertEquals(String.class, ClassUtils.getClassStrict(classLoader,
"java.lang.String", true));
+ // No whitespace normalization: whitespace-bearing spellings fail,
matching Class.forName.
+ assertThrows(ClassNotFoundException.class, () ->
ClassUtils.getClassStrict(" java.lang.String"));
+ assertThrows(ClassNotFoundException.class, () ->
ClassUtils.getClassStrict("java .lang.String"));
+ assertThrows(ClassNotFoundException.class, () ->
ClassUtils.getClassStrict("java.lang\t.String"));
+ assertThrows(ClassNotFoundException.class, () ->
ClassUtils.getClassStrict(classLoader, "java.lang.String ", true));
+ assertThrows(NullPointerException.class, () ->
ClassUtils.getClassStrict(null));
+ // The normalizing overloads keep their documented behavior.
+ assertEquals(String.class, ClassUtils.getClass(" java.lang.String"));
+ assertEquals(String.class, ClassUtils.getClass("java .lang.String"));
+ }
+
@Test
void testGetClassInvalidArguments() throws Exception {
assertGetClassThrowsNullPointerException(null);