Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ public abstract class AbstractMatcher<E> implements Serializable {
private static final Logger LOG = LogManager.getLogger(AbstractMatcher.class);
private static final Pattern WILDCARD_PATTERN = Pattern.compile("\\{(.)}");

/**
* <p>Values a wildcard-matched request path segment may take when it is substituted
* into a class-name-bearing slot (action/result/exception-mapping {@code class}).
* Rejects '.', '/', and any other character that could walk the substituted value
* out of the package the template author wrote, or that an {@code ObjectFactory}
* could otherwise treat specially.</p>
*
* @since 7.5.0
*/
private static final Pattern SAFE_CLASS_NAME_SEGMENT = Pattern.compile("[A-Za-z0-9_$]*");

/**
* <p> Handles all wildcard pattern matching. </p>
*/
Expand Down Expand Up @@ -199,6 +210,31 @@ protected Map<String, String> replaceParameters(Map<String, String> orig, Map<St
* @return The new value
*/
protected String convertParam(String val, Map<String, String> vars) {
return convertParam(val, vars, false);
}

/**
* <p> Inserts into a value wildcard-matched strings where specified
* with the {x} syntax. If a wildcard-matched value isn't found, the
* replacement token is turned into an empty string.
* </p>
*
* <p>Use {@code safeForClassName = true} for any slot that ends up being loaded as a
* class or bean name (action/result/exception-mapping {@code class}). A substituted
* value that would carry a '.', '/', or other non-identifier character into the class
* name is dropped (treated as empty) instead of substituted, so a wildcard segment
* cannot walk the resulting class name out of the package the template author wrote.
* The method-name slot already has an equivalent guard
* ({@link org.apache.struts2.config.entities.ActionConfig#isAllowedMethod(String)});
* this is the same guard for the class-name slot. Literal characters in {@code val}
* itself are never filtered -- only the values substituted in for {x}.</p>
*
* @param val The value to convert
* @param vars A Map of wildcard-matched strings
* @param safeForClassName restrict substituted values to {@code [A-Za-z0-9_$]*}
* @return The new value
*/
protected String convertParam(String val, Map<String, String> vars, boolean safeForClassName) {
if (val == null) {
return null;
}
Expand All @@ -207,7 +243,13 @@ protected String convertParam(String val, Map<String, String> vars) {

StringBuilder result = new StringBuilder();
while (wildcardMatcher.find()) {
wildcardMatcher.appendReplacement(result, vars.getOrDefault(wildcardMatcher.group(1), ""));
String replacement = vars.getOrDefault(wildcardMatcher.group(1), "");
if (safeForClassName && !SAFE_CLASS_NAME_SEGMENT.matcher(replacement).matches()) {
LOG.warn("Wildcard-matched segment '{}' rejected for class-name slot '{}' -- " +
"contains characters other than [A-Za-z0-9_$]", replacement, val);
replacement = "";
}
wildcardMatcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
}
wildcardMatcher.appendTail(result);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public ActionConfig convert(String path, ActionConfig orig,
return null;
}

String className = convertParam(orig.getClassName(), vars);
String className = convertParam(orig.getClassName(), vars, true);
String pkgName = convertParam(orig.getPackageName(), vars);

Map<String,String> params = replaceParameters(orig.getParams(), vars);
Expand All @@ -123,7 +123,7 @@ public ActionConfig convert(String path, ActionConfig orig,
for (String name : orig.getResults().keySet()) {
ResultConfig result = orig.getResults().get(name);
name = convertParam(name, vars);
ResultConfig r = new ResultConfig.Builder(name, convertParam(result.getClassName(), vars))
ResultConfig r = new ResultConfig.Builder(name, convertParam(result.getClassName(), vars, true))
.addParams(replaceParameters(result.getParams(), vars))
.build();
results.put(name, r);
Expand All @@ -132,7 +132,7 @@ public ActionConfig convert(String path, ActionConfig orig,
List<ExceptionMappingConfig> exs = new ArrayList<ExceptionMappingConfig>();
for (ExceptionMappingConfig ex : orig.getExceptionMappings()) {
String name = convertParam(ex.getName(), vars);
String exClassName = convertParam(ex.getExceptionClassName(), vars);
String exClassName = convertParam(ex.getExceptionClassName(), vars, true);
String exResult = convertParam(ex.getResult(), vars);
Map<String,String> exParams = replaceParameters(ex.getParams(), vars);
ExceptionMappingConfig e = new ExceptionMappingConfig.Builder(name, exClassName, exResult).addParams(exParams).build();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.config.impl;

import org.apache.struts2.XWorkTestCase;
import org.apache.struts2.config.entities.ActionConfig;
import org.apache.struts2.util.WildcardHelper;

import java.util.HashMap;
import java.util.Map;

/**
* The wildcard "{n}" substitution that fills in a
* {@code class=} (and method/package) template is unconfined -- any matched path
* segment, including one containing '.' or '/'-decoded dots, lands verbatim in the
* resulting class name. A sibling slot (the method name) IS gated, by
* {@link ActionConfig#isAllowedMethod(String)} at ActionConfigMatcher.java:113. There is
* no equivalent gate for the class name at ActionConfigMatcher.java:117 /
* AbstractMatcher.convertParam():201-215.
*
* Benign probe only: the "attacker" value is a harmless literal package/class suffix
* ("util.Harmless"), not a real sensitive class, and no class is actually loaded here --
* this test only proves the ActionConfig the matcher hands back contains the attacker's
* wildcard segment unconfined in getClassName(), which is what a default ObjectFactory
* would later pass to Class.forName/buildBean with no further check
* (DefaultActionFactory.buildAction -> objectFactory.buildBean(config.getClassName(), ...)).
*/
public class WildcardClassNameHardeningTest extends XWorkTestCase {

public void testClassNameWildcardRejectsNonIdentifierSegment() {
Map<String, ActionConfig> map = new HashMap<>();
// A typical CRUD-style wildcard mapping a developer might write:
// <action name="*" class="actions.{1}Action"> -- intended for simple names
// like "user" or "order", never audited against the request path.
map.put("*", new ActionConfig.Builder("package", "*", "actions.{1}Action")
.methodName("execute")
.build());

ActionConfigMatcher matcher = new ActionConfigMatcher(new WildcardHelper(), map, false);

// Benign "attacker" path segment: contains '.' so it walks the class name into a
// different package than the template author intended. No real/dangerous class
// name is used -- this is a structural probe, not an exploit payload.
ActionConfig matched = matcher.match("util.Harmless");

assertNotNull("ActionConfig should be matched", matched);
// GREEN (post-fix): the '.' in the matched segment is not a safe class-name
// character, so the substitution is dropped instead of walking the class out of
// the mapped package -- the attacker-controlled segment no longer changes the
// resolved package.
assertEquals("Class-name wildcard substitution must reject non-identifier characters",
"actions.Action", matched.getClassName());

// Benign identifier-only segment still substitutes normally.
ActionConfig normal = matcher.match("User");
assertEquals("actions.UserAction", normal.getClassName());
}

public void testMethodNameWildcardIsGated() {
// Control: the sibling slot (method name) IS gated by isAllowedMethod, so a
// non-allowed substituted method value causes convert() to return null instead
// of an ActionConfig that would invoke an arbitrary method.
Map<String, ActionConfig> map = new HashMap<>();
map.put("run/*", new ActionConfig.Builder("package", "run/*", "actions.RunAction")
.methodName("{1}")
.addAllowedMethod(java.util.Set.of("execute"))
.setStrictMethodInvocation(true)
.build());

ActionConfigMatcher matcher = new ActionConfigMatcher(new WildcardHelper(), map, false);

ActionConfig notAllowed = matcher.match("run/deleteAll");
assertNull("GREEN control: method substitution is gated by isAllowedMethod", notAllowed);

ActionConfig allowed = matcher.match("run/execute");
assertNotNull(allowed);
assertEquals("execute", allowed.getMethodName());
}
}
Loading