WW-5640 Add WebJars support to Struts core (#1765)

* WW-5640 docs: design for WebJars support in Struts core

Adds first-class WebJars support so client-side libraries can be
referenced by a version-less logical path and served through the
existing static-content pipeline. Grounded against 7.2.x source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 docs: implementation plan for WebJars support

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 build: add webjars-locator-lite dependency

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: add webjars config constants and defaults

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 docs: correct plan test framework to JUnit 4

core uses JUnit 4 + AssertJ + Mockito, not JUnit 5 Jupiter (no
Jupiter engine on the classpath). Test tasks translate accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: add WebJarUrlProvider resolution seam

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: register WebJarUrlProvider bean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: extend static content-type map for webjar assets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: serve webjar assets via static content loader

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 feat: add <s:webjar> tag and <@s.webjar> macro

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 docs: add generated tag reference for <s:webjar>

Annotation-processor-generated tag reference (attributes + description),
tracked like every other tag's docs under core/src/site/resources/tags/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 fix: address final review (log level, resolveUrl traversal test, javadoc)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WW-5640 refactor: address SonarCloud code smells

- getContentType: replace long if/else chain with a static extension->
  MIME map (S3776 cognitive complexity)
- DefaultWebJarUrlProvider.split: return Optional<String[]> instead of a
  null sentinel (S1168; Optional fits the reject semantics, empty-array
  would not)
- serving tests: rename local 'loader' -> 'webJarLoader' to stop hiding
  the ContentTypeProbe field (S1117)
- WebJarTest: use assertThat(writer).hasToString(...) (S5838)

S110 (WebJarTag inheritance depth) is inherent to the Struts tag base
class hierarchy shared by every tag; left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-07-06 15:03:05 +02:00
committed by GitHub
parent a09f2a93af
commit 789dbf3cd2
22 changed files with 2458 additions and 18 deletions
+11
View File
@@ -153,6 +153,17 @@
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-lite</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
@@ -195,6 +195,16 @@ public final class StrutsConstants {
*/
public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath";
/**
* Whether WebJars support is enabled (serving and URL building)
*/
public static final String STRUTS_WEBJARS_ENABLED = "struts.webjars.enabled";
/**
* Optional comma-separated allowlist of WebJar names permitted to be served (empty = all)
*/
public static final String STRUTS_WEBJARS_ALLOWLIST = "struts.webjars.allowlist";
/**
* A global flag to enable/disable html body escaping in tags, can be overwritten per tag
*/
@@ -434,6 +444,11 @@ public final class StrutsConstants {
*/
public static final String STRUTS_STATIC_CONTENT_LOADER = "struts.staticContentLoader";
/**
* The {@link org.apache.struts2.webjars.WebJarUrlProvider} implementation class
*/
public static final String STRUTS_WEBJARS_URL_PROVIDER = "struts.webjars.urlProvider";
/**
* The {@link org.apache.struts2.UnknownHandlerManager} implementation class
*/
@@ -0,0 +1,95 @@
/*
* 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.components;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.views.annotations.StrutsTag;
import org.apache.struts2.views.annotations.StrutsTagAttribute;
import org.apache.struts2.webjars.WebJarUrlProvider;
import java.io.IOException;
import java.io.Writer;
import java.util.Optional;
/**
* <p>Resolves a version-less WebJar resource path to a servable URL and writes it to the output
* (or stores it in a variable when {@code var} is set). Compose it with {@code <s:script>}/{@code <s:link>}
* or a raw {@code <link>}/{@code <script>} element.</p>
*
* <b>Examples</b>
* <pre>
* &lt;link rel="stylesheet" href="&lt;s:webjar path="bootstrap/css/bootstrap.min.css" /&gt;" /&gt;
* &lt;@s.webjar path="jquery/jquery.min.js"/&gt;
* </pre>
*/
@StrutsTag(
name = "webjar",
tldTagClass = "org.apache.struts2.views.jsp.WebJarTag",
description = "Resolve a version-less WebJar resource path to a servable URL")
public class WebJar extends ContextBean {
private static final Logger LOG = LogManager.getLogger(WebJar.class);
protected String path;
private final HttpServletRequest request;
private WebJarUrlProvider webJarUrlProvider;
public WebJar(ValueStack stack, HttpServletRequest request) {
super(stack);
this.request = request;
}
@Inject
public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
this.webJarUrlProvider = webJarUrlProvider;
}
@Override
public boolean end(Writer writer, String body) {
String logicalPath = findString(path);
Optional<String> url = (logicalPath == null)
? Optional.empty()
: webJarUrlProvider.resolveUrl(logicalPath, request);
if (url.isPresent()) {
if (StringUtils.isNotBlank(getVar())) {
putInContext(url.get());
} else {
try {
writer.write(url.get());
} catch (IOException e) {
LOG.error("Could not write WebJar URL for path '{}'", path, e);
}
}
}
return super.end(writer, body);
}
@StrutsTagAttribute(required = true,
description = "The version-less WebJar resource path, e.g. bootstrap/css/bootstrap.min.css")
public void setPath(String path) {
this.path = path;
}
}
@@ -84,6 +84,7 @@ import org.apache.struts2.util.reflection.ReflectionProvider;
import org.apache.struts2.validator.ActionValidatorManager;
import org.apache.struts2.views.freemarker.FreemarkerManager;
import org.apache.struts2.views.util.UrlHelper;
import org.apache.struts2.webjars.WebJarUrlProvider;
/**
* Selects the implementations of key framework extension points, using the loaded
@@ -430,6 +431,7 @@ public class StrutsBeanSelectionProvider extends AbstractBeanSelectionProvider {
alias(PatternMatcher.class, StrutsConstants.STRUTS_PATTERNMATCHER, builder, props);
alias(ContentTypeMatcher.class, StrutsConstants.STRUTS_CONTENT_TYPE_MATCHER, builder, props);
alias(StaticContentLoader.class, StrutsConstants.STRUTS_STATIC_CONTENT_LOADER, builder, props);
alias(WebJarUrlProvider.class, StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER, builder, props);
alias(UnknownHandlerManager.class, StrutsConstants.STRUTS_UNKNOWN_HANDLER_MANAGER, builder, props);
alias(UrlHelper.class, StrutsConstants.STRUTS_URL_HELPER, builder, props);
@@ -27,6 +27,7 @@ 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.webjars.WebJarUrlProvider;
import java.io.IOException;
import java.io.InputStream;
@@ -39,6 +40,8 @@ import java.util.Calendar;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.StringTokenizer;
/**
@@ -70,6 +73,8 @@ import java.util.StringTokenizer;
*/
public class DefaultStaticContentLoader implements StaticContentLoader {
protected static final String WEBJARS_REQUEST_PREFIX = "/webjars/";
/**
* Provide a logging instance.
*/
@@ -107,6 +112,13 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
protected boolean devMode;
protected WebJarUrlProvider webJarUrlProvider;
@Inject
public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
this.webJarUrlProvider = webJarUrlProvider;
}
/**
* Modify state of StrutsConstants.STRUTS_SERVE_STATIC_CONTENT setting.
*
@@ -209,6 +221,14 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
public void findStaticResource(String path, HttpServletRequest request, HttpServletResponse response)
throws IOException {
String name = cleanupPath(path);
if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
if (!findWebJarResource(name, path, request, response)) {
sendNotFound(response);
}
return;
}
for (String pathPrefix : pathPrefixes) {
URL resourceUrl = findResource(buildPath(name, pathPrefix));
if (resourceUrl != null) {
@@ -231,6 +251,32 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
}
}
sendNotFound(response);
}
/**
* Resolve and serve a WebJar asset requested under {@code <staticContentPath>/webjars/**}.
*
* @param name the request path with the static-content prefix stripped, e.g. {@code /webjars/jquery/jquery.min.js}
* @param path the original request path (used for content-type detection)
* @return true if the asset was resolved and streamed; false otherwise (caller sends 404)
*/
protected boolean findWebJarResource(String name, String path, HttpServletRequest request, HttpServletResponse response)
throws IOException {
String logicalPath = name.substring(WEBJARS_REQUEST_PREFIX.length());
Optional<String> resource = webJarUrlProvider.resolveResourcePath(logicalPath);
if (resource.isEmpty()) {
return false;
}
URL resourceUrl = findResource(resource.get());
if (resourceUrl == null) {
return false;
}
process(resourceUrl.openStream(), path, request, response);
return true;
}
protected void sendNotFound(HttpServletResponse response) {
try {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} catch (IOException e1) {
@@ -321,32 +367,43 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
}
/**
* Maps a lower-case file extension to its content type. Not using the code provided by
* activation.jar to avoid adding yet another dependency; this covers the files we serve up
* (Struts' own static assets plus WebJar assets).
*/
private static final Map<String, String> CONTENT_TYPES = Map.ofEntries(
Map.entry("js", "text/javascript"),
Map.entry("mjs", "text/javascript"),
Map.entry("css", "text/css"),
Map.entry("html", "text/html"),
Map.entry("txt", "text/plain"),
Map.entry("gif", "image/gif"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("png", "image/png"),
Map.entry("svg", "image/svg+xml"),
Map.entry("ico", "image/x-icon"),
Map.entry("woff2", "font/woff2"),
Map.entry("woff", "font/woff"),
Map.entry("ttf", "font/ttf"),
Map.entry("otf", "font/otf"),
Map.entry("eot", "application/vnd.ms-fontobject"),
Map.entry("json", "application/json"),
Map.entry("map", "application/json"));
/**
* Determine the content type for the resource name.
*
* @param name The resource name
* @return The mime type
* @return The mime type, or {@code null} if the extension is unknown
*/
protected String getContentType(String name) {
// NOT using the code provided activation.jar to avoid adding yet another dependency
// this is generally OK, since these are the main files we server up
if (name.endsWith(".js")) {
return "text/javascript";
} else if (name.endsWith(".css")) {
return "text/css";
} else if (name.endsWith(".html")) {
return "text/html";
} else if (name.endsWith(".txt")) {
return "text/plain";
} else if (name.endsWith(".gif")) {
return "image/gif";
} else if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
return "image/jpeg";
} else if (name.endsWith(".png")) {
return "image/png";
} else {
int dot = name.lastIndexOf('.');
if (dot < 0) {
return null;
}
return CONTENT_TYPES.get(name.substring(dot + 1));
}
/**
@@ -53,6 +53,7 @@ public class StrutsModels {
protected RadioModel radio;
protected SelectModel select;
protected SetModel set;
protected WebJarModel webjar;
protected SubmitModel submit;
protected ResetModel reset;
protected TextAreaModel textarea;
@@ -339,6 +340,13 @@ public class StrutsModels {
return set;
}
public WebJarModel getWebjar() {
if (webjar == null) {
webjar = new WebJarModel(stack, req, res);
}
return webjar;
}
public PropertyModel getProperty() {
if (property == null) {
property = new PropertyModel(stack, req, res);
@@ -0,0 +1,37 @@
/*
* 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.views.freemarker.tags;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.struts2.components.Component;
import org.apache.struts2.components.WebJar;
import org.apache.struts2.util.ValueStack;
public class WebJarModel extends TagModel {
public WebJarModel(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
super(stack, req, res);
}
@Override
protected Component getBean() {
return new WebJar(stack, req);
}
}
@@ -0,0 +1,48 @@
/*
* 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.views.jsp;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.struts2.components.Component;
import org.apache.struts2.components.WebJar;
import org.apache.struts2.util.ValueStack;
public class WebJarTag extends ContextBeanTag {
private static final long serialVersionUID = 1L;
protected String path;
@Override
public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
return new WebJar(stack, req);
}
@Override
protected void populateParams() {
super.populateParams();
WebJar webJar = (WebJar) component;
webJar.setPath(path);
}
public void setPath(String path) {
this.path = path;
}
}
@@ -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.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) {
Optional<String[]> parts = split(logicalPath);
if (parts.isEmpty()) {
return Optional.empty();
}
String[] p = parts.get();
String full = locator.fullPath(p[0], p[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) {
Optional<String[]> parts = split(logicalPath);
if (parts.isEmpty()) {
return Optional.empty();
}
String[] p = parts.get();
String versioned = locator.path(p[0], p[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 the two-element {webJarName, filePath} pair, or {@link Optional#empty()} if disabled,
* blank, single-segment, traversal-tainted, or allowlist-blocked
*/
private Optional<String[]> split(String logicalPath) {
if (!enabled || StringUtils.isBlank(logicalPath)) {
return Optional.empty();
}
String normalized = StringUtils.stripStart(logicalPath, "/");
if (normalized.contains("\\")) {
return Optional.empty();
}
for (String segment : normalized.split("/")) {
if (segment.equals("..") || segment.equals(".")) {
LOG.debug("Rejecting WebJar path with traversal segment: {}", logicalPath);
return Optional.empty();
}
}
int slash = normalized.indexOf('/');
if (slash < 1 || slash == normalized.length() - 1) {
return Optional.empty();
}
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 Optional.empty();
}
return Optional.of(new String[]{webJarName, filePath});
}
private boolean isAllowed(String webJarName) {
return allowlist.isEmpty() || allowlist.contains(webJarName);
}
}
@@ -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();
}
@@ -106,6 +106,12 @@ struts.ui.staticContentPath=/static
### headers)
struts.serve.static.browserCache=true
### WebJars support
### Master switch for resolving/serving WebJar assets under <staticContentPath>/webjars/**
struts.webjars.enabled=true
### Optional comma-separated allowlist of WebJar names (empty = all WebJars on the classpath)
struts.webjars.allowlist=
### Set this to false if you wish to disable implicit dynamic method invocation
### via the URL request. This includes URLs like foo!bar.action, as well as params
### like method:bar (but not action:foo).
+2
View File
@@ -207,6 +207,8 @@
<bean type="org.apache.struts2.dispatcher.StaticContentLoader"
class="org.apache.struts2.dispatcher.DefaultStaticContentLoader" name="struts"/>
<bean type="org.apache.struts2.webjars.WebJarUrlProvider"
class="org.apache.struts2.webjars.DefaultWebJarUrlProvider" name="struts"/>
<bean type="org.apache.struts2.UnknownHandlerManager"
class="org.apache.struts2.DefaultUnknownHandlerManager" name="struts"/>
@@ -0,0 +1,40 @@
<table class="tag-reference">
<tr>
<td colspan="6"><h4>Dynamic Attributes Allowed:</h4> false</td>
</tr>
<tr>
<td colspan="6"><hr/></td>
</tr>
<tr>
<th class="tag-header"><h4>Name</h4></th>
<th class="tag-header"><h4>Required</h4></th>
<th class="tag-header"><h4>Default</h4></th>
<th class="tag-header"><h4>Evaluated</h4></th>
<th class="tag-header"><h4>Type</h4></th>
<th class="tag-header"><h4>Description</h4></th>
</tr>
<tr>
<td class="tag-attribute">path</td>
<td class="tag-attribute"><strong>true</strong></td>
<td class="tag-attribute"></td>
<td class="tag-attribute">false</td>
<td class="tag-attribute">String</td>
<td class="tag-attribute">The version-less WebJar resource path, e.g. bootstrap/css/bootstrap.min.css</td>
</tr>
<tr>
<td class="tag-attribute">performClearTagStateForTagPoolingServers</td>
<td class="tag-attribute">false</td>
<td class="tag-attribute">false</td>
<td class="tag-attribute">false</td>
<td class="tag-attribute">Boolean</td>
<td class="tag-attribute">Whether to clear all tag state during doEndTag() processing (if applicable)</td>
</tr>
<tr>
<td class="tag-attribute">var</td>
<td class="tag-attribute">false</td>
<td class="tag-attribute"></td>
<td class="tag-attribute">false</td>
<td class="tag-attribute">String</td>
<td class="tag-attribute">Name used to reference the value pushed into the Value Stack (scope: action).</td>
</tr>
</table>
@@ -0,0 +1 @@
Resolve a version-less WebJar resource path to a servable URL
@@ -0,0 +1,81 @@
/*
* 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.components;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.webjars.WebJarUrlProvider;
import org.junit.Test;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class WebJarTest {
private WebJar newComponent(ValueStack stack, HttpServletRequest request, WebJarUrlProvider provider) {
Map<String, Object> context = new HashMap<>();
when(stack.getContext()).thenReturn(context);
WebJar webJar = new WebJar(stack, request);
webJar.setWebJarUrlProvider(provider);
return webJar;
}
@Test
public void writesResolvedUrl() {
ValueStack stack = mock(ValueStack.class);
when(stack.findString("jquery/jquery.min.js")).thenReturn("jquery/jquery.min.js");
HttpServletRequest request = mock(HttpServletRequest.class);
WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
when(provider.resolveUrl("jquery/jquery.min.js", request))
.thenReturn(Optional.of("/myapp/static/webjars/jquery/3.7.1/jquery.min.js"));
WebJar webJar = newComponent(stack, request, provider);
webJar.setPath("jquery/jquery.min.js");
StringWriter writer = new StringWriter();
webJar.start(writer);
webJar.end(writer, "");
assertThat(writer).hasToString("/myapp/static/webjars/jquery/3.7.1/jquery.min.js");
}
@Test
public void unresolvedPathWritesNothing() {
ValueStack stack = mock(ValueStack.class);
when(stack.findString("nope/x.js")).thenReturn("nope/x.js");
HttpServletRequest request = mock(HttpServletRequest.class);
WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
when(provider.resolveUrl("nope/x.js", request)).thenReturn(Optional.empty());
WebJar webJar = newComponent(stack, request, provider);
webJar.setPath("nope/x.js");
StringWriter writer = new StringWriter();
webJar.start(writer);
webJar.end(writer, "");
assertThat(writer.toString()).isEmpty();
}
}
@@ -0,0 +1,119 @@
/*
* 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.dispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class DefaultStaticContentLoaderWebJarTest {
private final ContentTypeProbe loader = new ContentTypeProbe();
/** Exposes the protected getContentType for assertion. */
static class ContentTypeProbe extends DefaultStaticContentLoader {
String type(String name) {
return getContentType(name);
}
}
@Test
public void mapsWebJarAssetTypes() {
assertThat(loader.type("x.woff2")).isEqualTo("font/woff2");
assertThat(loader.type("x.woff")).isEqualTo("font/woff");
assertThat(loader.type("x.ttf")).isEqualTo("font/ttf");
assertThat(loader.type("x.otf")).isEqualTo("font/otf");
assertThat(loader.type("x.eot")).isEqualTo("application/vnd.ms-fontobject");
assertThat(loader.type("x.svg")).isEqualTo("image/svg+xml");
assertThat(loader.type("x.map")).isEqualTo("application/json");
assertThat(loader.type("x.json")).isEqualTo("application/json");
assertThat(loader.type("x.ico")).isEqualTo("image/x-icon");
assertThat(loader.type("x.mjs")).isEqualTo("text/javascript");
}
@Test
public void preservesExistingTypes() {
assertThat(loader.type("x.js")).isEqualTo("text/javascript");
assertThat(loader.type("x.css")).isEqualTo("text/css");
assertThat(loader.type("x.png")).isEqualTo("image/png");
assertThat(loader.type("x.unknown")).isNull();
}
private DefaultStaticContentLoader newLoader(boolean enabled) {
DefaultStaticContentLoader webJarLoader = new DefaultStaticContentLoader();
webJarLoader.setServeStaticContent("true");
webJarLoader.setStaticContentPath("/static");
webJarLoader.setServeStaticBrowserCache("true");
webJarLoader.setEncoding("UTF-8");
org.apache.struts2.webjars.DefaultWebJarUrlProvider provider =
new org.apache.struts2.webjars.DefaultWebJarUrlProvider();
provider.setEnabled(String.valueOf(enabled));
provider.setAllowlist("");
provider.setStaticContentPath("/static");
webJarLoader.setWebJarUrlProvider(provider);
return webJarLoader;
}
@Test
public void servesKnownWebJarAssetWithContentType() throws Exception {
DefaultStaticContentLoader webJarLoader = newLoader(true);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
ByteArrayOutputStream captured = new ByteArrayOutputStream();
when(response.getOutputStream())
.thenReturn(new WebJarTestServletOutputStream(captured));
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response);
verify(response).setContentType("text/javascript");
verify(response, never())
.sendError(HttpServletResponse.SC_NOT_FOUND);
assertThat(captured.size()).isGreaterThan(0);
}
@Test
public void unknownWebJarAssetReturns404() throws Exception {
DefaultStaticContentLoader webJarLoader = newLoader(true);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
webJarLoader.findStaticResource("/static/webjars/nope/nope.js", request, response);
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}
@Test
public void disabledWebJarsReturns404() throws Exception {
DefaultStaticContentLoader webJarLoader = newLoader(false);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response);
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
@@ -0,0 +1,50 @@
/*
* 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.dispatcher;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import java.io.IOException;
import java.io.OutputStream;
/** Minimal ServletOutputStream backed by an OutputStream, for tests. */
public class WebJarTestServletOutputStream extends ServletOutputStream {
private final OutputStream delegate;
public WebJarTestServletOutputStream(OutputStream delegate) {
this.delegate = delegate;
}
@Override
public void write(int b) throws IOException {
delegate.write(b);
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {
// no-op
}
}
@@ -0,0 +1,110 @@
/*
* 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 resolveUrlRejectsTraversal() {
assertThat(provider.resolveUrl("jquery/../../../etc/passwd", request)).isEmpty();
assertThat(provider.resolveUrl("../jquery/jquery.min.js", request)).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();
}
}