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 @@ -60,6 +60,13 @@ public class StrutsParameterAuthorizer implements ParameterAuthorizer {

private static final Logger LOG = LogManager.getLogger(StrutsParameterAuthorizer.class);

/**
* {@link OgnlUtil#getBeanInfo(Class)} introspects with {@link Object} as the stop class, so this one never
* appears among the property descriptors and cannot be told apart from a genuinely unknown name by evidence
* alone. It is not unknown, though: it resolves to {@link Object#getClass()} on every object alike.
*/
private static final String CLASS_PROPERTY = "class";

private boolean requireAnnotations = false;
private boolean requireAnnotationsTransitionMode = false;
private boolean devMode = false;
Expand Down Expand Up @@ -115,28 +122,106 @@ public boolean isAuthorized(String parameterName, Object target, Object action)

long paramDepth = parameterName.codePoints().mapToObj(c -> (char) c).filter(NESTING_CHARS::contains).count();

// ModelDriven exemption: only exempt when the action explicitly implements ModelDriven
// and the target is its model object. This prevents non-ModelDriven root objects
// (e.g. JSONInterceptor's configurable rootObject) from bypassing annotation checks.
if (target != action && action instanceof ModelDriven) {
LOG.debug("ModelDriven target detected (action implements ModelDriven), exempting from @StrutsParameter annotation requirement");
return true;
int nestingIndex = indexOfAny(parameterName, NESTING_CHARS_STR);
String rootProperty = nestingIndex == -1 ? parameterName : parameterName.substring(0, nestingIndex);
if (rootProperty.isEmpty()) {
LOG.debug("Parameter [{}] begins with a nesting character, so it names no root property to authorize; rejecting",
parameterName);
return false;
}
String normalisedRootProperty = Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);

// Transition mode: depth-0 (non-nested) parameters are exempt
// Transition mode: depth-0 (non-nested) parameters are exempt. Checked before the ModelDriven
// exemption so that it also covers a ModelDriven action's own members, which would otherwise
// have no migration path once the exemption is scoped to the model.
if (requireAnnotationsTransitionMode && paramDepth == 0) {
LOG.debug("Annotation transition mode enabled, exempting non-nested parameter [{}] from @StrutsParameter annotation requirement",
parameterName);
return true;
}

int nestingIndex = indexOfAny(parameterName, NESTING_CHARS_STR);
String rootProperty = nestingIndex == -1 ? parameterName : parameterName.substring(0, nestingIndex);
String normalisedRootProperty = Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);
// ModelDriven exemption: only exempt when the action explicitly implements ModelDriven
// and the target is its model object. This prevents non-ModelDriven root objects
// (e.g. JSONInterceptor's configurable rootObject) from bypassing annotation checks.
if (target != action && action instanceof ModelDriven) {
return isAuthorizedOnModelDrivenAction(normalisedRootProperty, target, action, paramDepth);
}

return hasValidAnnotatedMember(normalisedRootProperty, target, paramDepth);
}

/**
* Decides authorization for a {@link ModelDriven} action, whose model is on top of the value stack.
* <p>
* Returning an object from {@code getModel()} declares that object to be request surface, so anything the
* model itself can take is exempt from the {@link StrutsParameter} requirement. The exemption stops there:
* OGNL resolves the parameter name against the whole stack, which also holds the action, so a property
* declared on the action is still subject to the annotation requirement. Without that distinction a
* ModelDriven action would silently expose its own members.
* <p>
* A property declared on neither is allowed, since it cannot be reaching a member of the action - typically
* it is bound by a custom OGNL property accessor on the model, such as a Map-backed model. {@code class} is
* the exception to that: it is invisible to introspection here rather than absent, so it is rejected instead
* of taking the fallback, which keeps a ModelDriven action from handing OGNL a {@code class} path that the
* ordinary non-ModelDriven path would have rejected for want of an annotation.
*/
protected boolean isAuthorizedOnModelDrivenAction(String rootProperty, Object model, Object action, long paramDepth) {
if (declaresProperty(model, rootProperty, paramDepth)) {
LOG.debug("Property [{}] belongs to the ModelDriven model, exempting from @StrutsParameter annotation requirement",
rootProperty);
return true;
}
if (!declaresProperty(action, rootProperty, paramDepth)) {
if (CLASS_PROPERTY.equals(rootProperty)) {
LOG.debug("Property [class] is not an unknown property but Object.getClass() on every object alike, so the fallback for a custom accessor does not apply; rejecting");
return false;
}
LOG.debug("Property [{}] is declared on neither the model nor the action, exempting from @StrutsParameter annotation requirement",
rootProperty);
return true;
}
LOG.debug("Property [{}] is declared on the ModelDriven action itself, applying the @StrutsParameter annotation requirement",
rootProperty);
return hasValidAnnotatedMember(rootProperty, action, paramDepth);
}

/**
* Whether {@code target} can itself take {@code property} at this depth - as a bean property whose relevant
* accessor exists, the setter for a depth-0 parameter and the getter for a nested one, or as a public instance
* field. Any {@link StrutsParameter} annotation is irrelevant here; this asks only what the object can absorb.
* <p>
* It has to be bindability rather than the name alone, because OGNL walks the stack until an object actually
* accepts the assignment. A model which merely names the property without being able to take it - a getter-only
* property under a depth-0 parameter, say - does not absorb that parameter: OGNL moves on to the action, and an
* exemption granted on the name alone would hand over the action's own member, which is the very thing this
* scoping exists to prevent. Inherited public fields count for the same reason, that OGNL can set them.
*/
protected boolean declaresProperty(Object target, String property, long paramDepth) {
BeanInfo beanInfo = getBeanInfo(target);
if (beanInfo != null && Arrays.stream(beanInfo.getPropertyDescriptors())
.filter(desc -> desc.getName().equals(property))
.anyMatch(desc -> (paramDepth == 0 ? desc.getWriteMethod() : desc.getReadMethod()) != null)) {
return true;
}
return declaresBindablePublicField(target, property, paramDepth);
}

/**
* Whether {@code target} exposes {@code property} as a public instance field that this parameter could bind
* through. {@link Class#getFields} covers inherited fields as well as declared ones, an inherited public field
* being just as settable as a declared one. Static fields are not per-instance request surface, and a final
* field cannot take a depth-0 assignment, so neither counts as absorbing the parameter.
* <p>
* Scanning the fields and matching the name here, rather than looking the name up with {@code getField},
* keeps the request-derived property name out of a reflection lookup. The two select the same fields.
*/
protected boolean declaresBindablePublicField(Object target, String property, long paramDepth) {
return Arrays.stream(ultimateClass(target).getFields())
.filter(field -> field.getName().equals(property))
.anyMatch(field -> !Modifier.isStatic(field.getModifiers())
&& (paramDepth > 0 || !Modifier.isFinal(field.getModifiers())));
}

protected boolean hasValidAnnotatedMember(String rootProperty, Object target, long paramDepth) {
LOG.debug("Checking target [{}] for a matching, correctly annotated member for property [{}]",
target.getClass().getSimpleName(), rootProperty);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,127 @@
assertThat(authorizer.isAuthorized("nested.deep", model, action)).isTrue();
}

@Test
public void modelDriven_unannotatedActionMember_rejected() {
// The exemption covers the model, which is declared request surface by getModel().
// It must not reach members declared on the action itself.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("actionSecret", action.getModel(), action)).isFalse();
}

@Test
public void modelDriven_annotatedActionMember_authorized() {

Check warning on line 144 in core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace these 4 tests with a single Parameterized one.

See more on https://sonarcloud.io/project/issues?id=apache_struts&issues=AaBB5j7F2vS79_8iEk26&open=AaBB5j7F2vS79_8iEk26&pullRequest=1872
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("actionAllowed", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_modelProperty_stillAuthorizedWithoutAnnotation() {
// The whole point of the exemption: model properties need no annotation.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("name", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_propertyOnNeitherModelNorAction_authorized() {
// A model bound through a custom OGNL property accessor (e.g. a Map-backed model) declares no
// bean property, and such a name cannot be reaching a member of the action either.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("noSuchPropertyAnywhere", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_modelPropertyShadowingUnannotatedActionProperty_authorized() {
// Declared on both. OGNL resolves against the stack top, which is the model, so the model's
// property wins and needs no annotation even though the action's namesake is unannotated.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("shared", action.getModel(), action)).isTrue();
}

@Test
public void transitionMode_modelDrivenUnannotatedActionMember_exempt() {
// Transition mode exists so an application can turn requireAnnotations on while it works
// through annotating. It must reach ModelDriven actions too, or the actions affected by
// scoping the exemption have no migration path.
authorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("actionSecret", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_readOnlyModelPropertyShadowingUnannotatedActionSetter_rejected() {
// Verified against a real value stack: with the model on top and only a getter for "shadow",
// OGNL cannot assign to the model and moves on to the action, whose unannotated setter takes
// the value. Exempting on the name alone would therefore expose the action's own member.
var action = new ModelActionWithReadOnlyModelProperty();
assertThat(authorizer.isAuthorized("shadow", action.getModel(), action)).isFalse();
}

@Test
public void modelDriven_readOnlyModelProperty_stillAuthorizedForNestedParameter() {
// A getter is all a nested parameter needs of the root property: OGNL reads "shadow" from the
// model and assigns further in. The model does absorb this one, so the exemption still applies.
var action = new ModelActionWithReadOnlyModelProperty();
assertThat(authorizer.isAuthorized("shadow.anything", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_inheritedPublicFieldOnAction_rejected() {
// OGNL sets inherited public fields as readily as declared ones, so a field the action inherits
// is still the action's own member and still needs the annotation.
var action = new ModelActionInheritingPublicField();
assertThat(authorizer.isAuthorized("inheritedSecret", action.getModel(), action)).isFalse();
}

@Test
public void modelDriven_inheritedPublicFieldOnModel_authorized() {
// The mirror case: a public field the model inherits is model surface like any other.
var action = new ModelActionWithInheritingModel();
assertThat(authorizer.isAuthorized("inheritedModelField", action.getModel(), action)).isTrue();
}

@Test
public void modelDriven_staticFieldNamesakeOfUnannotatedActionProperty_rejected() {
// A constant is not per-instance request surface and cannot absorb the parameter, so it must not
// stand in for the model the way a real field would.
var action = new ModelActionWithConstantNamesake();
assertThat(authorizer.isAuthorized("constant", action.getModel(), action)).isFalse();
}

@Test
public void modelDriven_classProperty_rejected() {
// OgnlUtil introspects with Object as the stop class, so "class" shows up on no descriptor list
// and looks like a name declared nowhere - the shape the custom-accessor fallback exempts. It is
// not unknown, it is Object.getClass() on everything, and the non-ModelDriven path rejects it for
// want of an annotation. The exemption must not make a ModelDriven action the exception.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized("class.classLoader.foo", action.getModel(), action)).isFalse();
assertThat(authorizer.isAuthorized("class", action.getModel(), action)).isFalse();
}

@Test
public void nonModelDrivenAction_classProperty_rejected() {
// The behaviour the case above is being aligned with.
var action = new SecureAction();
assertThat(authorizer.isAuthorized("class.classLoader.foo", action, action)).isFalse();
}

@Test
public void parameterNameBeginningWithNestingChar_rejected() {
// Such a name has no root property to authorize. It used to reach charAt(0) on an empty string.
var action = new ModelActionWithOwnMembers();
assertThat(authorizer.isAuthorized(".actionSecret", action.getModel(), action)).isFalse();
assertThat(authorizer.isAuthorized("[0].actionSecret", action.getModel(), action)).isFalse();
assertThat(authorizer.isAuthorized("(actionSecret)", action.getModel(), action)).isFalse();
}

@Test
public void parameterNameBeginningWithNestingChar_nonModelDriven_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized(".annotatedProp", action, action)).isFalse();
assertThat(authorizer.isAuthorized("[0].annotatedProp", action, action)).isFalse();
}

@Test
public void nonModelDrivenAction_differentTarget_notExempt() {
// Regression test: when target != action but action does NOT implement ModelDriven,
Expand Down Expand Up @@ -267,9 +388,91 @@
public Pojo getModel() { return new Pojo(); }
}

public static class ModelActionWithOwnMembers implements ModelDriven<Pojo> {
private final Pojo model = new Pojo();
private String actionSecret;
private String actionAllowed;

@Override
public Pojo getModel() { return model; }

// NO @StrutsParameter — declared on the action, so the model exemption must not cover it
public void setActionSecret(String actionSecret) { this.actionSecret = actionSecret; }
public String getActionSecret() { return actionSecret; }

@StrutsParameter
public void setActionAllowed(String actionAllowed) { this.actionAllowed = actionAllowed; }
public String getActionAllowed() { return actionAllowed; }

// Namesake of a model property, deliberately unannotated
private String shared;
public void setShared(String shared) { this.shared = shared; }
public String getShared() { return shared; }
}

public static class ReadOnlyShadowModel {
public String getShadow() { return "read-only"; }
}

public static class ModelActionWithReadOnlyModelProperty implements ModelDriven<ReadOnlyShadowModel> {
private final ReadOnlyShadowModel model = new ReadOnlyShadowModel();
private String shadow;

@Override
public ReadOnlyShadowModel getModel() { return model; }

// NO @StrutsParameter — the model only reads "shadow", so a depth-0 parameter lands here
public void setShadow(String shadow) { this.shadow = shadow; }
public String getShadow() { return shadow; }
}

public static class BaseWithPublicField {
public String inheritedSecret;
}

public static class ModelActionInheritingPublicField extends BaseWithPublicField implements ModelDriven<Pojo> {
private final Pojo model = new Pojo();

@Override
public Pojo getModel() { return model; }
}

public static class ModelInheritingPublicField extends BaseWithPublicModelField {
}

public static class BaseWithPublicModelField {
public String inheritedModelField;
}

public static class ModelActionWithInheritingModel implements ModelDriven<ModelInheritingPublicField> {
private final ModelInheritingPublicField model = new ModelInheritingPublicField();

@Override
public ModelInheritingPublicField getModel() { return model; }
}

public static class ModelWithConstant {
public static final String constant = "not request surface";

Check failure on line 455 in core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this constant name to match the regular expression '^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$'.

See more on https://sonarcloud.io/project/issues?id=apache_struts&issues=AaBHAzvfKNzgUaIETn2m&open=AaBHAzvfKNzgUaIETn2m&pullRequest=1872
}

public static class ModelActionWithConstantNamesake implements ModelDriven<ModelWithConstant> {
private final ModelWithConstant model = new ModelWithConstant();
private String constant;

@Override
public ModelWithConstant getModel() { return model; }

// NO @StrutsParameter
public void setConstant(String constant) { this.constant = constant; }
public String getConstant() { return constant; }
}

public static class Pojo {
private String name;
private String shared;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getShared() { return shared; }
public void setShared(String shared) { this.shared = shared; }
}
}
Loading