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

lukaszlenart pushed a commit to branch WW-5640-webjars-support
in repository https://gitbox.apache.org/repos/asf/struts.git

commit ec23813db5b3f83a426dabe18d1a9aace6be7f39
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Jul 1 11:22:23 2026 +0200

    WW-5640 feat: add WebJarUrlProvider resolution seam
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../struts2/webjars/DefaultWebJarUrlProvider.java  | 149 +++++++++++++++++++++
 .../apache/struts2/webjars/WebJarUrlProvider.java  |  51 +++++++
 .../webjars/DefaultWebJarUrlProviderTest.java      | 104 ++++++++++++++
 3 files changed, 304 insertions(+)

diff --git 
a/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java 
b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
new file mode 100644
index 000000000..d01027324
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
@@ -0,0 +1,149 @@
+/*
+ * 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.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.dispatcher.StaticContentLoader;
+import org.apache.struts2.inject.Inject;
+import org.webjars.WebJarVersionLocator;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Default {@link WebJarUrlProvider} backed by a singleton {@link 
WebJarVersionLocator}
+ * (from {@code webjars-locator-lite}). Thread-safe.
+ */
+public class DefaultWebJarUrlProvider implements WebJarUrlProvider {
+
+    private static final Logger LOG = 
LogManager.getLogger(DefaultWebJarUrlProvider.class);
+
+    private static final String WEBJARS_URL_SEGMENT = "/webjars/";
+
+    private final WebJarVersionLocator locator = new WebJarVersionLocator();
+
+    private boolean enabled = true;
+    private Set<String> allowlist = Collections.emptySet();
+    private String uiStaticContentPath = 
StaticContentLoader.DEFAULT_STATIC_CONTENT_PATH;
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ENABLED, required = false)
+    public void setEnabled(String enabled) {
+        this.enabled = BooleanUtils.toBoolean(enabled);
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ALLOWLIST, required = false)
+    public void setAllowlist(String allowlist) {
+        Set<String> names = new HashSet<>();
+        if (StringUtils.isNotBlank(allowlist)) {
+            for (String name : allowlist.split(",")) {
+                String trimmed = name.trim();
+                if (!trimmed.isEmpty()) {
+                    names.add(trimmed);
+                }
+            }
+        }
+        this.allowlist = Collections.unmodifiableSet(names);
+    }
+
+    @Inject(StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH)
+    public void setStaticContentPath(String uiStaticContentPath) {
+        this.uiStaticContentPath = 
StaticContentLoader.Validator.validateStaticContentPath(uiStaticContentPath);
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public Optional<String> resolveResourcePath(String logicalPath) {
+        String[] parts = split(logicalPath);
+        if (parts == null) {
+            return Optional.empty();
+        }
+        String full = locator.fullPath(parts[0], parts[1]);
+        if (full == null || 
!full.startsWith(WebJarVersionLocator.WEBJARS_PATH_PREFIX + "/")) {
+            return Optional.empty();
+        }
+        return Optional.of(full);
+    }
+
+    @Override
+    public Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request) {
+        String[] parts = split(logicalPath);
+        if (parts == null) {
+            return Optional.empty();
+        }
+        String versioned = locator.path(parts[0], parts[1]);
+        if (versioned == null) {
+            return Optional.empty();
+        }
+        StringBuilder url = new StringBuilder();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotEmpty(contextPath) && !"/".equals(contextPath)) {
+            url.append(contextPath);
+        }
+        
url.append(uiStaticContentPath).append(WEBJARS_URL_SEGMENT).append(versioned);
+        return Optional.of(url.toString());
+    }
+
+    /**
+     * Normalize, validate and split a logical path into {webJarName, 
filePath}.
+     *
+     * @return a two-element array, or {@code null} if disabled, blank, 
single-segment,
+     *         traversal-tainted, or allowlist-blocked
+     */
+    private String[] split(String logicalPath) {
+        if (!enabled || StringUtils.isBlank(logicalPath)) {
+            return null;
+        }
+        String normalized = StringUtils.stripStart(logicalPath, "/");
+        if (normalized.contains("\\")) {
+            return null;
+        }
+        for (String segment : normalized.split("/")) {
+            if (segment.equals("..") || segment.equals(".")) {
+                LOG.debug("Rejecting WebJar path with traversal segment: {}", 
logicalPath);
+                return null;
+            }
+        }
+        int slash = normalized.indexOf('/');
+        if (slash < 1 || slash == normalized.length() - 1) {
+            return null;
+        }
+        String webJarName = normalized.substring(0, slash);
+        String filePath = normalized.substring(slash + 1);
+        if (!isAllowed(webJarName)) {
+            LOG.debug("WebJar '{}' is not on the allowlist", webJarName);
+            return null;
+        }
+        return new String[]{webJarName, filePath};
+    }
+
+    private boolean isAllowed(String webJarName) {
+        return allowlist.isEmpty() || allowlist.contains(webJarName);
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java 
b/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java
new file mode 100644
index 000000000..8c4b64777
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java
@@ -0,0 +1,51 @@
+/*
+ * 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.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Optional;
+
+/**
+ * Resolves version-less WebJar logical paths (e.g. {@code 
bootstrap/css/bootstrap.min.css}) to either a
+ * concrete classpath resource under {@code META-INF/resources/webjars/} (for 
serving) or a servable URL
+ * (for tags/macros). Resolution is constrained to the WebJars root, honours 
an optional allowlist and the
+ * {@code struts.webjars.enabled} switch, and fails closed (empty result) when 
unresolved or blocked.
+ */
+public interface WebJarUrlProvider {
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @return the concrete classpath resource path (e.g.
+     *         {@code 
META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css}), or empty
+     */
+    Optional<String> resolveResourcePath(String logicalPath);
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @param request     the current request (used for the servlet context 
path)
+     * @return a servable URL, or empty
+     */
+    Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request);
+
+    /**
+     * @return whether WebJars support is enabled
+     */
+    boolean isEnabled();
+}
diff --git 
a/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
 
b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
new file mode 100644
index 000000000..6f207eee6
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DefaultWebJarUrlProviderTest {
+
+    private DefaultWebJarUrlProvider provider;
+    private HttpServletRequest request;
+
+    @Before
+    public void setUp() {
+        provider = new DefaultWebJarUrlProvider();
+        provider.setEnabled("true");
+        provider.setAllowlist("");
+        provider.setStaticContentPath("/static");
+        request = mock(HttpServletRequest.class);
+        when(request.getContextPath()).thenReturn("/myapp");
+    }
+
+    @Test
+    public void resolvesKnownResourceToVersionedClasspathPath() {
+        assertThat(provider.resolveResourcePath("jquery/jquery.min.js"))
+            .hasValueSatisfying(p -> assertThat(p)
+                .startsWith("META-INF/resources/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    public void resolvesKnownResourceToServableUrl() {
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> assertThat(u)
+                .startsWith("/myapp/static/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    public void unknownWebjarResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("no-such-lib/x.js")).isEmpty();
+        assertThat(provider.resolveUrl("no-such-lib/x.js", request)).isEmpty();
+    }
+
+    @Test
+    public void traversalIsRejected() {
+        
assertThat(provider.resolveResourcePath("jquery/../../../etc/passwd")).isEmpty();
+        
assertThat(provider.resolveResourcePath("../jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    public void allowlistBlocksNonListedWebjar() {
+        provider.setAllowlist("bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    public void allowlistPermitsListedWebjar() {
+        provider.setAllowlist("jquery, bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isPresent();
+    }
+
+    @Test
+    public void disabledResolvesEmpty() {
+        provider.setEnabled("false");
+        assertThat(provider.isEnabled()).isFalse();
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", 
request)).isEmpty();
+    }
+
+    @Test
+    public void rootContextPathIsNotDuplicated() {
+        when(request.getContextPath()).thenReturn("/");
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> 
assertThat(u).startsWith("/static/webjars/jquery/"));
+    }
+
+    @Test
+    public void blankOrSingleSegmentPathResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("")).isEmpty();
+        assertThat(provider.resolveResourcePath("jquery")).isEmpty();
+    }
+}

Reply via email to