This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch WW-5676-array-package-resolution-decision
in repository https://gitbox.apache.org/repos/asf/struts.git

commit 1076fa9ac52984b24db347ad727a771bc219b614
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Aug 23 18:05:35 2026 +0200

    WW-5676 test(ognl): pin the array package-resolution decision
    
    WW-5676 asked whether toPackageName should resolve arrays to the element
    type's package instead of the empty package. It should not, and the
    premise the ticket was filed on turns out to be wrong.
    
    The ticket assumed clone() is reachable on an array target with the array
    type as its declaring class, leaving java.io.File[] free to slip past the
    java.io entry in struts.excludedPackageNames. Array clone() is a
    JVM-internal method and is absent from the reflection view: on Temurin 17,
    21 and 25, getMethods() on an array class returns only java.lang.Object's
    methods, getDeclaredMethods() is empty and getMethod("clone") throws.
    
    java.lang.Object is in turn permanently excluded - it is the built-in
    default of excludedClasses, and useExcludedClasses accumulates onto that
    default rather than replacing it, so no configuration can drop it. Since
    checkExclusionList tests the declaring class before the package, an array
    target is always denied at the first check and the package comparison is
    never reached.
    
    Resolving arrays to the element package would therefore tighten nothing
    while genuinely loosening the allowlist, so the behaviour stays as is.
    This commit records the reasoning where it can rot loudly instead of
    quietly: SecurityMemberAccessArrayTargetTest pins both facts the decision
    rests on, and the comment on toPackageName no longer states the false
    premise. No behaviour change.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
---
 .../apache/struts2/ognl/SecurityMemberAccess.java  |   9 +-
 .../ognl/SecurityMemberAccessArrayTargetTest.java  | 151 +++++++++++++++++++++
 2 files changed, 158 insertions(+), 2 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java 
b/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
index 3e626e664..4d552d594 100644
--- a/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
+++ b/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
@@ -393,8 +393,13 @@ public class SecurityMemberAccess implements MemberAccess {
         // call, whereas getPackageName() is computed once and cached on the 
Class. getPackage()
         // returns null for exactly arrays, primitives and void, so the guard 
reproduces the
         // previous result for every input. Note that void.class.isPrimitive() 
is true.
-        // Arrays deliberately keep the empty package here: getPackageName() 
would resolve them
-        // to the element type's package, which would loosen the allowlist. 
See WW-5674.
+        // Arrays deliberately keep the empty package. WW-5676 weighed 
resolving them to the element
+        // type's package and decided against it: that would tighten nothing, 
because the package
+        // check is unreachable for array targets -- every reflectively 
reachable member of an array
+        // class declares in java.lang.Object, which is permanently excluded 
-- while it would loosen
+        // the allowlist, implicitly allowlisting com.app.Thing[] for any 
application configuring
+        // struts.allowlist.packageNames=com.app. 
SecurityMemberAccessArrayTargetTest pins that
+        // reasoning; reopen WW-5676 if it ever stops holding.
         if (clazz.isArray() || clazz.isPrimitive()) {
             return "";
         }
diff --git 
a/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessArrayTargetTest.java
 
b/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessArrayTargetTest.java
new file mode 100644
index 000000000..e229145e8
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessArrayTargetTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.struts2.ognl;
+
+import ognl.OgnlContext;
+import org.apache.struts2.util.StrutsProxyService;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.lang.reflect.Method;
+import java.util.HashSet;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Pins the reasoning behind WW-5676, which decided that {@link 
SecurityMemberAccess#toPackageName}
+ * must keep resolving arrays to the empty package rather than to the element 
type's package.
+ * <p>
+ * The concern WW-5676 was raised to investigate was that an array of an 
excluded-package type, say
+ * {@code java.io.File[]}, resolves to the empty package and so slips past
+ * {@code struts.excludedPackageNames} even though {@code java.io} is excluded 
by default. That
+ * reads like a defensive gap, but the package check is unreachable for array 
targets, because of
+ * the two facts pinned below:
+ * <ol>
+ *   <li>every member reflectively reachable on an array class declares in 
{@code java.lang.Object}
+ *       &mdash; array {@code clone()} is a JVM-internal method absent from 
the reflection view; and</li>
+ *   <li>{@code java.lang.Object} is permanently excluded &mdash; it is the 
built-in default of
+ *       {@code excludedClasses} and the setters only ever accumulate onto 
that default.</li>
+ * </ol>
+ * {@code checkExclusionList} tests the declaring class before the package, so 
it always denies at
+ * the first check and never reaches the package comparison.
+ * <p>
+ * Resolving arrays to the element package would therefore tighten nothing, 
while genuinely
+ * loosening the allowlist: {@code struts.allowlist.packageNames=com.app} 
would begin to allowlist
+ * {@code com.app.Thing[]} implicitly, which today requires an explicit
+ * {@code struts.allowlist.classes} entry. These tests fail loudly if either 
fact stops holding,
+ * because that is what would turn the decision around.
+ */
+public class SecurityMemberAccessArrayTargetTest {
+
+    private static final List<Class<?>> ARRAY_SHAPES = List.of(
+            String[].class,
+            File[].class,
+            int[].class,
+            Object[][].class,
+            SecurityMemberAccess[].class);
+
+    private OgnlContext context;
+    private SecurityMemberAccess sma;
+
+    @Before
+    public void setUp() {
+        context = ognl.Ognl.createDefaultContext(null);
+        ProviderAllowlist providerAllowlist = mock(ProviderAllowlist.class);
+        ThreadAllowlist threadAllowlist = mock(ThreadAllowlist.class);
+        when(providerAllowlist.getProviderAllowlist()).thenReturn(new 
HashSet<>());
+        when(threadAllowlist.getAllowlist()).thenReturn(new HashSet<>());
+        sma = new SecurityMemberAccess(providerAllowlist, threadAllowlist);
+        sma.setProxyService(new StrutsProxyService(new 
StrutsProxyCacheFactory<>("1000", "basic")));
+    }
+
+    /**
+     * Fact one. If a future JDK exposes further members on array classes, the 
unreachability
+     * argument breaks and WW-5676 has to be reopened.
+     */
+    @Test
+    public void everyReflectiveMemberOfAnArrayClassDeclaresInObject() {
+        for (Class<?> arrayClass : ARRAY_SHAPES) {
+            assertThat(arrayClass.getMethods())
+                    .as("public methods of %s", arrayClass.getName())
+                    .isNotEmpty()
+                    .allSatisfy(method -> 
assertThat(method.getDeclaringClass()).isEqualTo(Object.class));
+            assertThat(arrayClass.getDeclaredMethods())
+                    .as("declared methods of %s", arrayClass.getName())
+                    .isEmpty();
+            assertThat(arrayClass.getFields())
+                    .as("public fields of %s, including the synthetic length", 
arrayClass.getName())
+                    .isEmpty();
+        }
+    }
+
+    /**
+     * Fact one, continued. Array {@code clone()} is a JVM-internal method: 
the JLS gives array
+     * types a public {@code clone()}, but it is not reflectively 
discoverable, so it can never
+     * reach {@code checkExclusionList} with the array type as its declaring 
class.
+     */
+    @Test
+    public void arrayCloneIsNotReflectivelyReachable() {
+        for (Class<?> arrayClass : ARRAY_SHAPES) {
+            assertThatExceptionOfType(NoSuchMethodException.class)
+                    .as("clone() of %s", arrayClass.getName())
+                    .isThrownBy(() -> arrayClass.getMethod("clone"));
+        }
+    }
+
+    /**
+     * Fact two. {@code useExcludedClasses} folds into the existing set rather 
than replacing it,
+     * so no configuration can drop the built-in {@code java.lang.Object} 
entry.
+     */
+    @Test
+    public void objectStaysExcludedWhateverIsConfigured() {
+        assertThat(sma.isClassExcluded(Object.class))
+                .as("java.lang.Object is excluded by default")
+                .isTrue();
+
+        sma.useExcludedClasses("java.lang.Class,java.lang.Runtime");
+
+        assertThat(sma.isClassExcluded(Object.class))
+                .as("java.lang.Object stays excluded after excludedClasses is 
configured without it")
+                .isTrue();
+    }
+
+    /**
+     * The payoff: no member of an array target is accessible, so the empty 
package name that
+     * {@code toPackageName} returns for arrays is never compared against
+     * {@code struts.excludedPackageNames} in the first place.
+     */
+    @Test
+    public void noMemberOfAnArrayTargetIsAccessible() {
+        for (boolean allowlistEnabled : new boolean[]{true, false}) {
+            sma.useEnforceAllowlistEnabled(String.valueOf(allowlistEnabled));
+            File[] target = {new File("/tmp")};
+            for (Method member : target.getClass().getMethods()) {
+                assertThat(sma.isAccessible(context, target, member, 
member.getName()))
+                        .as("allowlistEnabled=%s member=%s", allowlistEnabled, 
member)
+                        .isFalse();
+            }
+        }
+    }
+}

Reply via email to