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(); + } +}
