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

thiagohp pushed a commit to branch 5.9.x
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git


The following commit(s) were added to refs/heads/5.9.x by this push:
     new fead92b31 New classpath asset protection rules
fead92b31 is described below

commit fead92b3151eb2e3f6a5e39f7afceecabf6a1c54
Author: Ben Weidig <[email protected]>
AuthorDate: Wed Oct 7 08:19:06 2026 +0200

    New classpath asset protection rules
---
 .../assets/ClasspathAssetRequestHandler.java       |  42 +++++
 .../org/apache/tapestry5/modules/AssetsModule.java | 106 +++++++-----
 .../assets/ClasspathAssetProtectionRuleTest.java   | 181 +++++++++++++++++++++
 3 files changed, 292 insertions(+), 37 deletions(-)

diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetRequestHandler.java
 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetRequestHandler.java
index c93d4e5d1..a8ceadb52 100644
--- 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetRequestHandler.java
+++ 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetRequestHandler.java
@@ -25,7 +25,10 @@ import 
org.apache.tapestry5.services.assets.AssetRequestHandler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.File;
 import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
 
 /**
  * A handler for asset requests for classpath assets (within a specific 
folder).
@@ -93,10 +96,49 @@ public class ClasspathAssetRequestHandler implements 
AssetRequestHandler
             }
             handled = false;
         }
+        else if (isDirectory(resource))
+        {
+            if (LOGGER.isWarnEnabled())
+            {
+                LOGGER.warn("Blocked request for classpath asset '" + 
resourcePath +
+                        "': the path resolves to a directory, not a file.");
+            }
+            handled = false;
+        }
         else
         {
             handled = path.stream(resource);
         }
         return handled;
     }
+
+    /**
+     * Tells whether the resolved resource is a directory rather than a 
streamable file. A
+     * non-existent resource is not a directory (it is handled as a normal 
"not found" downstream).
+     */
+    private static boolean isDirectory(Resource resource)
+    {
+        final URL url = resource.toURL();
+
+        if (url == null)
+        {
+            return false;
+        }
+
+        if ("file".equalsIgnoreCase(url.getProtocol()))
+        {
+            try
+            {
+                return new File(url.toURI()).isDirectory();
+            }
+            catch (URISyntaxException ex)
+            {
+                return new File(url.getPath()).isDirectory();
+            }
+        }
+
+        // Inside archives (jar:, etc.) a directory entry, when the 
classloader exposes one at all,
+        // is conventionally represented with a trailing slash.
+        return url.getPath().endsWith("/");
+    }
 }
diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/modules/AssetsModule.java 
b/tapestry-core/src/main/java/org/apache/tapestry5/modules/AssetsModule.java
index dc414c448..7e000749d 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/modules/AssetsModule.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/AssetsModule.java
@@ -12,6 +12,8 @@
 
 package org.apache.tapestry5.modules;
 
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -421,63 +423,93 @@ public class AssetsModule
     {
         return chainBuilder.build(ClasspathAssetProtectionRule.class, rules);
     }
-    
-    public static void contributeClasspathAssetProtectionRule(
-            OrderedConfiguration<ClasspathAssetProtectionRule> configuration,
-            @Symbol(TapestryHttpSymbolConstants.PRODUCTION_MODE)
-            boolean productionMode)
-    {
-        ClasspathAssetProtectionRule classFileRule = (s) -> 
s.toLowerCase().endsWith(".class");
-        configuration.add("ClassFile", classFileRule);
-        ClasspathAssetProtectionRule propertiesFileRule = (s) -> 
s.toLowerCase().endsWith(".properties");
-        configuration.add("PropertiesFile", propertiesFileRule);
-        ClasspathAssetProtectionRule xmlFileRule = (s) -> 
s.toLowerCase().endsWith(".xml");
-        configuration.add("XMLFile", xmlFileRule);
-        ClasspathAssetProtectionRule tmlFileRule = (s) -> 
s.toLowerCase().endsWith(".tml");
-        configuration.add("TemplateFile", tmlFileRule);
-        ClasspathAssetProtectionRule folderRule = (s) -> isFolderToBlock(s);
-        configuration.add("Folder", folderRule);
 
-        // TAP5-2835: Don't serve source maps in production
-        if (productionMode)
-        {
-            ClasspathAssetProtectionRule mapFileRule = (s) -> 
s.toLowerCase().endsWith(".map");
-            configuration.add("MapFile", mapFileRule);
-        }
-    }
+    /**
+     * Sensitive resource types, blocked when the resource's file name ends 
with the (lower-case) suffix.
+     */
+    private static final Map<String, String> BLOCKED_FILE_SUFFIXES = 
blockedFileSuffixes();
 
-    @Primary
-    public static ContextAssetProtectionRule buildContextAssetProtectionRule(
-            List<ContextAssetProtectionRule> rules, ChainBuilder chainBuilder)
+    private static Map<String, String> blockedFileSuffixes()
     {
-        return chainBuilder.build(ContextAssetProtectionRule.class, rules);
+        Map<String, String> map = new LinkedHashMap<>(22); // = 16 elements / 
0.75 default load factor
+        map.put("ClassFile", ".class");
+        map.put("PropertiesFile", ".properties");
+        map.put("XMLFile", ".xml");
+        map.put("TemplateFile", ".tml");
+        map.put("MFFile", ".mf");
+        map.put("PEMFile", ".pem");
+        map.put("JKSFile", ".jks");
+        map.put("P12File", ".p12");
+        map.put("P8File", ".p8");
+        map.put("PKCS8File", ".pkcs8");
+        map.put("ASCFile", ".asc");
+        map.put("GPGFile", ".gpg");
+        map.put("PGPFile", ".pgp");
+        map.put("KeystoreFile", ".keystore");
+        map.put("KeyFile", ".key");
+        map.put("PFXFile", ".pfx");
+        return Collections.unmodifiableMap(map);
     }
 
-    @Contribute(ContextAssetProtectionRule.class)
-    public static void contributeContextAssetProtectionRule(
-            OrderedConfiguration<ContextAssetProtectionRule> configuration,
+     /**
+     * Sensitive resources, blocked by the complete (lower-case) resource's 
file name.
+     */
+    private static final Map<String, String> BLOCKED_FILE_NAMES = 
blockedFileNames();
 
+    private static Map<String, String> blockedFileNames()
+    {
+        Map<String, String> map = new LinkedHashMap<>();
+        map.put("DockerFile", "dockerfile");
+        map.put("IdRSAFile", "id_rsa");
+        map.put("IdEd25519File", "id_ed25519");
+        map.put("IdEcdsaFile", "id_ecdsa");
+        return Collections.unmodifiableMap(map);
+    }
+    public static void contributeClasspathAssetProtectionRule(
+            OrderedConfiguration<ClasspathAssetProtectionRule> configuration,
             @Symbol(TapestryHttpSymbolConstants.PRODUCTION_MODE)
             boolean productionMode)
     {
-        // Source maps expose original, unminified source; don't let them leak 
out in production.
-        // Applications that want source maps served in production can 
override this contribution.
+        BLOCKED_FILE_SUFFIXES.forEach((id, suffix) ->
+                configuration.add(id, (s) -> 
lowerCaseLastSegment(s).endsWith(suffix)));
+
+        // Block all filenames (and last-segment folders) starting with a dot.
+        configuration.add("Dotfile", (s) -> 
lowerCaseLastSegment(s).startsWith("."));
+
+        BLOCKED_FILE_NAMES.forEach((id, blocked) ->
+                configuration.add(id, (s) -> 
lowerCaseLastSegment(s).equals(blocked)));
+
+        // Requests that resolve to a directory (rather than a file) are 
refused directly by
+        // ClasspathAssetRequestHandler, which can inspect the actual resolved 
Resource.
+        // A path string alone cannot distinguish a folder from an 
extension-less file here.
+
+        // TAP5-2835: Don't serve source maps in production
         if (productionMode)
         {
-            ContextAssetProtectionRule mapFileRule = (s) -> 
s.toLowerCase().endsWith(".map");
+            ClasspathAssetProtectionRule mapFileRule = (s) -> 
lowerCaseLastSegment(s).endsWith(".map");
             configuration.add("MapFile", mapFileRule);
         }
     }
 
-    final private static boolean isFolderToBlock(String path)
+    /**
+     * Returns the last path segment (the file name), lower-cased, with any 
backslashes normalized
+     * to forward slashes first.
+     */
+    private static String lowerCaseLastSegment(String path)
     {
         path = path.replace('\\', '/');
         final int lastIndex = path.lastIndexOf('/');
         if (lastIndex >= 0)
         {
-            path = path.substring(lastIndex);
+            path = path.substring(lastIndex + 1);
         }
-        return !path.contains(".");
+        return path.toLowerCase();
+    }
+
+    @Primary
+    public static ContextAssetProtectionRule buildContextAssetProtectionRule(
+            List<ContextAssetProtectionRule> rules, ChainBuilder chainBuilder)
+    {
+        return chainBuilder.build(ContextAssetProtectionRule.class, rules);
     }
-    
 }
diff --git 
a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetProtectionRuleTest.java
 
b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetProtectionRuleTest.java
new file mode 100644
index 000000000..9c8e25ed4
--- /dev/null
+++ 
b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/assets/ClasspathAssetProtectionRuleTest.java
@@ -0,0 +1,181 @@
+// Copyright 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.
+// 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.tapestry5.internal.services.assets;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.tapestry5.commons.OrderedConfiguration;
+import org.apache.tapestry5.modules.AssetsModule;
+import org.apache.tapestry5.services.ClasspathAssetProtectionRule;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Exercises every rule contributed by
+ * {@link AssetsModule#contributeClasspathAssetProtectionRule}.
+ * Rather than re-implementing the rules, it captures the real contributions 
and composes them
+ * exactly as the service does ("blocked if any rule says so"), so the 
assertions can never
+ * drift from the production logic.
+ */
+class ClasspathAssetProtectionRuleTest
+{
+    /**
+     * Builds the real, composed protection rule for the given mode. Also 
asserts that no two
+     * contributions share an id (building throws otherwise). Package-visible 
so the handler test
+     * can reuse the real rules instead of a mirror.
+     */
+    static ClasspathAssetProtectionRule realRule(boolean productionMode)
+    {
+        final Map<String, ClasspathAssetProtectionRule> rules = new 
LinkedHashMap<>();
+
+        OrderedConfiguration<ClasspathAssetProtectionRule> configuration =
+                new OrderedConfiguration<ClasspathAssetProtectionRule>()
+        {
+            @Override
+            public void add(String id, ClasspathAssetProtectionRule object, 
String... constraints)
+            {
+                if (rules.put(id, object) != null)
+                {
+                    throw new IllegalStateException("Duplicate contribution 
id: " + id);
+                }
+            }
+
+            @Override
+            public void override(String id, ClasspathAssetProtectionRule 
object, String... constraints)
+            {
+                throw new UnsupportedOperationException();
+            }
+
+            @Override
+            public void addInstance(String id, Class<? extends 
ClasspathAssetProtectionRule> clazz, String... constraints)
+            {
+                throw new UnsupportedOperationException();
+            }
+
+            @Override
+            public void overrideInstance(String id, Class<? extends 
ClasspathAssetProtectionRule> clazz, String... constraints)
+            {
+                throw new UnsupportedOperationException();
+            }
+        };
+
+        AssetsModule.contributeClasspathAssetProtectionRule(configuration, 
productionMode);
+
+        return path -> rules.values().stream().anyMatch(rule -> 
rule.block(path));
+    }
+
+    private static final ClasspathAssetProtectionRule PRODUCTION = 
realRule(true);
+
+    private static final ClasspathAssetProtectionRule DEVELOPMENT = 
realRule(false);
+
+    /**
+     * Covers all extension rules, the dot-file rule, every exact file-name 
rule (case-insensitively),
+     * and path-separator independence.
+     */
+    @ParameterizedTest
+    @ValueSource(strings = {
+            // --- extension rules (BLOCKED_FILE_SUFFIXES) ---
+            "com/example/Service.class",
+            "com/example/messages.properties",
+            "com/example/config.xml",
+            "com/example/page.tml",
+            "META-INF/MANIFEST.MF",         // .mf, matched case-insensitively
+            "certs/server.pem",
+            "certs/store.jks",
+            "certs/cert.p12",
+            "certs/key.p8",
+            "certs/key.pkcs8",
+            "certs/signature.asc",
+            "certs/secret.gpg",
+            "certs/secret.pgp",
+            "certs/app.keystore",
+            "certs/server.key",
+            "certs/cert.pfx",
+            // --- dot-file rule (must match on the file name, not just the 
root) ---
+            "config/.env",
+            "a/b/.gitignore",
+            ".npmrc",
+            // --- exact file-name rules (BLOCKED_FILE_NAMES), 
case-insensitive ---
+            "build/Dockerfile",
+            "build/DOCKERFILE",
+            "ssh/id_rsa",
+            "ssh/id_ed25519",
+            "ssh/id_ecdsa",
+            "ID_RSA",
+            // --- path-separator independence (backslashes) ---
+            "config\\server.pem",
+            "build\\Dockerfile",
+    })
+    void blockedInEveryMode(String path)
+    {
+        assertTrue(PRODUCTION.block(path), "should be blocked (production 
mode): " + path);
+        assertTrue(DEVELOPMENT.block(path), "should be blocked (development 
mode): " + path);
+    }
+
+    /**
+     * Guard against over-blocking: ordinary web assets, and near-misses of 
the sensitive rules,
+     * must still be served. Notably {@code id_rsa.pub} (a public key) is not 
blocked, because the
+     * name rules match exactly rather than by prefix.
+     * Extension-less names are no longer blocked by any rule (whether such a 
path is a directory
+     * is decided by the request handler at stream time, not here).
+     */
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "assets/app.js",
+            "assets/app.min.js",
+            "styles/site.css",
+            "img/logo.png",
+            "fonts/font.woff2",
+            "data/config.json",
+            "docs/readme.txt",
+            "keys/id_rsa.pub",              // public key: exact-match rule 
must NOT block it
+            "build/Dockerfile.template",    // not an exact "dockerfile" match
+            "docs/LICENSE",                 // extension-less file: no longer 
blocked by a rule
+            "META-INF/services",            // extension-less name 
(folder-ness decided at stream time)
+            "bin/run",                      // extension-less file
+    })
+    void allowedInEveryMode(String path)
+    {
+        assertFalse(PRODUCTION.block(path), "should be served (production 
mode): " + path);
+        assertFalse(DEVELOPMENT.block(path), "should be served (development 
mode): " + path);
+    }
+
+    /**
+     * Source maps are the one mode-dependent rule: blocked in production, 
served in development.
+     */
+    @Test
+    void sourceMapsBlockedOnlyInProduction()
+    {
+        assertTrue(PRODUCTION.block("assets/app.js.map"), "source map must be 
blocked in production");
+        assertFalse(DEVELOPMENT.block("assets/app.js.map"), "source map should 
be served in development");
+    }
+
+    /**
+     * Building the chain throws on a duplicate contribution id, so a 
successful build is the
+     * assertion that all ids are distinct.
+     */
+    @Test
+    void contributionIdsAreUnique()
+    {
+        assertNotNull(realRule(true));
+        assertNotNull(realRule(false));
+    }
+}

Reply via email to