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);

Reply via email to