Compare commits

..

10 Commits

Author SHA1 Message Date
Lukasz Lenart b039dc5079 WW-5326 fix(rat): adjust exclude patterns for RAT and adds missing header with license 2026-04-07 07:38:42 +02:00
Lukasz Lenart 6374e31384 WW-5326 fix(tiles): use raw OgnlContext in tiles accessors
Tiles accessors are called by OGNL internal machinery with raw OgnlContext,
not within a Struts evaluation context, so they cannot use StrutsContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 19:27:16 +02:00
Lukasz Lenart 803b5cbbd0 WW-5326 docs: add Jira reference to spec and plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 19:18:21 +02:00
Lukasz Lenart 1dda92ed23 WW-5326 test(ognl): update tests for StrutsContext migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 19:17:10 +02:00
Lukasz Lenart cda38c79db WW-5326 refactor(tiles): parameterize tiles OGNL accessors with StrutsContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 19:01:25 +02:00
Lukasz Lenart fbb904e9c6 WW-5326 refactor(ognl): replace Ognl.createDefaultContext with direct StrutsContext construction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:41:27 +02:00
Lukasz Lenart f7923fc704 WW-5326 refactor(ognl): parameterize all accessor implementations with StrutsContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:31:58 +02:00
Lukasz Lenart 47718f5a8a WW-5326 refactor(ognl): parameterize core interfaces with StrutsContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:19:55 +02:00
Lukasz Lenart e2e8fa1f33 WW-5326 feat(ognl): introduce StrutsContext extending OgnlContext<StrutsContext>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:14:29 +02:00
Lukasz Lenart 71c204f04b WW-5326 build(deps): bump OGNL from 3.4.10 to 3.5.0-BETA4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 18:10:50 +02:00
137 changed files with 2023 additions and 5745 deletions
+2 -11
View File
@@ -23,20 +23,11 @@ github:
# it does not work because our github teams are private/secret, see INFRA-25666
require_code_owner_reviews: false
required_approving_review_count: 0
support/struts-6-x-x:
release/*:
# contexts are the names of checks that must pass.
required_status_checks:
contexts:
- "Build and Test (8)"
required_pull_request_reviews:
# it does not work because our github teams are private/secret, see INFRA-25666
require_code_owner_reviews: false
required_approving_review_count: 0
support/release-6-*:
# contexts are the names of checks that must pass.
required_status_checks:
contexts:
- "Build and Test (8)"
- "Build and Test (JDK 8)"
required_pull_request_reviews:
# it does not work because our github teams are private/secret, see INFRA-25666
require_code_owner_reviews: false
@@ -1,90 +0,0 @@
---
name: triaging-security-reports
description: Use when a vulnerability or security report arrives for triage, when assessing a CVE/RCE/OGNL/injection claim against the code, or when drafting a reply to a security researcher — to research the claim from source without trusting the reporter and without fabricating your own facts.
---
# Triaging Security Reports
## Overview
A security report is a **claim to be tested, not a finding to be confirmed or rebutted**. The reporter may be right, wrong, partially right, or right about the symptom and wrong about the cause. Your job is to independently re-derive the truth from current source.
**Core principle:** Every factual statement that ends up in your assessment or reply — the reporter's claims *and your own* — must be traced to current source code before you write it down. The most common failure is not believing the reporter; it is **inventing supporting facts to justify a verdict you already reached.**
**Process authority:** [`SECURITY.md`](../../../SECURITY.md) is the source of truth for the disclosure process (private handling, assessment checklist, reporting rules). Read it. This skill governs *how you research and respond*, not the process itself.
## The Iron Rule
```
NO CLAIM IN A SECURITY RESPONSE WITHOUT A FILE:LINE YOU READ THIS SESSION.
```
Applies to the verdict, every mitigation you cite, and every "default" you state. If you can't point to the line, you can't write the sentence.
## Research: report-blind, not report-led
Read the report once to know what to investigate. Then **research as if you were auditing that area cold** — do not let the report's framing drive your search.
For each claim, independently verify:
| Reporter asserts | You must verify from source |
|---|---|
| A line number ("bug is at X:392") | Read that line **and its call path** — is it even reachable as described? |
| A severity / CVSS | Re-derive from actual exploitability, not their number |
| "No mitigation / no gate exists" | Search for gates, filters, allowlists, authorizers *yourself* — absence claims are the most often wrong |
| "Default configuration" | Check the **effective runtime default**, not one source (see trap below) |
| "Same as CVE-XXXX" | Confirm the mechanism actually matches; analogy ≠ equivalence |
| A working PoC | Trace whether the payload survives every filter on the path |
If the report has **no reproducible PoC against a default config**, that is itself a triage outcome — say so per `SECURITY.md`.
## The effective-default trap
A Java field initializer and the shipped config can disagree. Reading only one produces a confident, wrong claim.
```java
private boolean requireAnnotations = false; // field initializer
```
```properties
struts.parameters.requireAnnotations=true # default.properties OVERRIDES it
```
**The effective default is `true`.** Always trace the full chain: field initializer → `@Inject` setter → `default.properties` → any struts.xml override. State the *effective runtime* value, and cite the file that actually wins.
## Vulnerability vs. operator responsibility
"In the default configuration" is a crutch — drop it. Decide the real question:
- **Is it a vulnerability?** Then it's a vulnerability whether or not it's the default. Handle it privately per `SECURITY.md`.
- **Does it require an operator to opt into an insecure configuration?** A documented, opt-in setting (e.g. `cookiesName=*`, `devMode=true`) that works as advertised is the operator's responsibility, provided the docs carry the warning. Say "X works as documented; the operator owns the security implications of enabling it" — not "not a vuln *in the default config*."
- **Is the RCE/escalation only reachable via application code the framework can't constrain?** (e.g. an action that moves an uploaded file to a web root.) Then it's an application concern, not a framework vulnerability — state that boundary explicitly.
## Drafting the reply
- Lead with the verdict and the *reason*, both grounded in file:line.
- Cite a source for every mitigation you mention. If you didn't verify it this session, delete the sentence.
- Prefer "works as documented / operator responsibility" framing over "default configuration."
- **Don't over-promise.** Before pledging a hardening change, check it doesn't already exist (it often does) and that you intend to actually do it.
- Acknowledge anything the reporter got right (e.g. correct CVE-fix verification) — it builds the relationship and signals you actually read it.
- Keep it private: no public issue, PR, Jira, or list thread before triage. Never open a PR that is itself the security fix (see [`CLAUDE.md`](../../../CLAUDE.md)).
## Red Flags — STOP
- About to write "this is mitigated by X" — did you read X's line *this session*?
- About to state a "default" from a field initializer — did you check `default.properties`?
- Citing the reporter's line number without having traced its call path.
- Asserting "no gate / no check exists" without having grepped for it.
- Two of your own claims contradict each other → at least one is unverified. Stop and verify both.
- Promising a fix/warning "we'll add" without checking it isn't already there.
- Writing "not a vulnerability in the default configuration" → reframe as vuln-or-not + operator responsibility.
## Common Mistakes
| Mistake | Reality |
|---|---|
| "Reporter cited line 392, so that's the bug site" | A line is only a bug if it's *reachable* as described. Trace callers. |
| "The field defaults to false, so the gate is off by default" | `default.properties` may override it to true. Check the effective value. |
| "I'll add a mitigation to strengthen the rejection" | An unverified mitigation that's wrong discredits the whole response. Verify or omit. |
| "It rejects the payload, obviously" | Confirm the specific PoC string fails the specific filter (e.g. full-match regex `ACCEPTED_PATTERN`). |
| "We should add a startup warning" | Grep first — the warning frequently already exists. |
| "Not a vuln in default config" | Either it's a vuln or it's operator-owned opt-in. The default-config hedge muddies both. |
+1 -5
View File
@@ -8,16 +8,12 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
target-branch: "main"
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 4
target-branch: "support/struts-6-x-x"
target-branch: "release/struts-6-8-x"
ignore:
- dependency-name: "org.eclipse.jetty:jetty-maven-plugin"
- dependency-name: "com.github.ben-manes.caffeine:caffeine"
+3 -4
View File
@@ -20,7 +20,6 @@ on:
branches:
- 'main'
- 'release/*'
- 'support/*'
pull_request:
permissions:
@@ -53,12 +52,12 @@ jobs:
java-version: 17
cache: 'maven'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.36.2
uses: github/codeql-action/init@v4.35.1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4.36.2
uses: github/codeql-action/autobuild@v4.35.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.36.2
uses: github/codeql-action/analyze@v4.35.1
with:
category: "/language:${{matrix.language}}"
-1
View File
@@ -21,7 +21,6 @@ on:
branches:
- 'main'
- 'release/*'
- 'support/*'
permissions: read-all
+2 -2
View File
@@ -58,13 +58,13 @@ jobs:
publish_results: true
- name: "Upload artifact"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@eda5730a8bfb740e03a28087a958444c646e5842 # 2.22.11
uses: github/codeql-action/upload-sarif@c618c9bddbf8ce520050acf14e9bb6c220e22931 # 2.22.11
with:
sarif_file: results.sarif
-30
View File
@@ -1,30 +0,0 @@
# Vulnerability Research Agent
You are helping a security researcher evaluate and report potential vulnerabilities in Apache Struts.
[`SECURITY.md`](SECURITY.md) is the source of truth for the Apache Struts vulnerability reporting process. **Read it first and follow it.** This file is a short
LLM-facing wrapper around that policy; it does not replace it.
## Workflow
Before drafting any report, opening an issue, posting publicly, or reaching a security conclusion:
1. **Pre-reporting checks** — complete every step in [`SECURITY.md` § Before Reporting](SECURITY.md#before-reporting):
- read the Struts security policy,
- read the Struts security guidelines,
- check previously disclosed vulnerabilities and Security Bulletins.
2. **Assess** the finding against the questions in [`SECURITY.md` § Assessment](SECURITY.md#assessment). If the answers do not still point to a likely new
framework vulnerability, stop and explain — do not draft a new report.
3. **Report privately** to `security@struts.apache.org` following [`SECURITY.md` § Private Report Requirements](SECURITY.md#private-report-requirements) and
[§ Report Quality Rules](SECURITY.md#report-quality-rules).
Do not open a public GitHub issue, Jira issue, pull request, mailing list thread, or discussion for a suspected vulnerability before private triage.
## Rules for AI Agents
- **Never submit a pull request that fixes a suspected vulnerability.** Before opening any PR, verify the change is not a security patch — OGNL injection,
parameter filtering bypass, file upload exploit, authentication or authorization bypass, RCE, SSRF, path traversal, deserialization, XSS in framework
components, etc. If it is, stop and direct the researcher to report it privately to `security@struts.apache.org` instead. Vulnerability fixes go through
the private security process, not public PRs.
- Do not speculate beyond what can be demonstrated. If severity is uncertain, say so explicitly.
- If the issue turns out to be application misconfiguration, an already-disclosed CVE, or a non-Struts problem, stop and explain — do not draft a new report.
+1 -11
View File
@@ -63,13 +63,6 @@ protected File createTemporaryFile(String fileName, Path location) {
}
```
## Security Reports & Scans
For any security-related activity — vulnerability scans, security analysis, drafting security reports — **[`SECURITY.md`](SECURITY.md) is the source of truth**.
Read it first and follow its pre-reporting checks, assessment checklist, and reporting requirements. Reports must be sent privately to
`security@struts.apache.org`; do not open a public GitHub issue, Jira issue, pull request, or mailing list thread for a suspected vulnerability before private
triage. [`AGENTS.md`](AGENTS.md) is a shorter LLM-facing wrapper around the same process.
## Testing
Tests use JUnit 5 with AssertJ assertions and Mockito for mocking. Run with `mvn test -DskipAssembly`.
@@ -78,7 +71,4 @@ Tests use JUnit 5 with AssertJ assertions and Mockito for mocking. Run with `mvn
- **Title format**: `WW-XXXX Description` (Jira ticket ID required)
- **Link ticket in description**: `Fixes [WW-XXXX](https://issues.apache.org/jira/browse/WW-XXXX)`
- **Issue tracker**: https://issues.apache.org/jira/projects/WW
- **Never submit a PR that fixes a suspected vulnerability.** Before opening a PR, verify the change is not a security patch (OGNL injection, parameter
filtering bypass, file upload exploit, auth bypass, RCE, SSRF, path traversal, deserialization, XSS in framework components, etc.). If it is, stop and report
it privately to `security@struts.apache.org` — see [`SECURITY.md`](SECURITY.md).
- **Issue tracker**: https://issues.apache.org/jira/projects/WW
Vendored
+3 -3
View File
@@ -105,7 +105,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'support/struts-6-x-x'
branch 'release/struts-6-8-x'
}
}
steps {
@@ -119,7 +119,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'support/struts-6-x-x'
branch 'release/struts-6-8-x'
}
}
steps {
@@ -132,7 +132,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'support/struts-6-x-x'
branch 'release/struts-6-8-x'
}
}
steps {
+7 -93
View File
@@ -5,17 +5,13 @@
Please visit the [Releases](https://struts.apache.org/releases.html#prior-releases) page to see full information about each version
and what potential vulnerability it can have:
| Version | Supported |
|---------|-----------|
| 7.x | yes |
| 6.x.x | yes |
| 2.5.x | no |
| 2.3.x | no |
| 2.2.x | no |
| 2.1.x | no |
| 2.0.x | no |
| Version | Supported |
|---------|--------------------|
| 7.x | :white_check_mark: |
| 6.7.x | :white_check_mark: |
| 2.5.x | |
## Reporting New Security Issues with the Apache Struts
## Reporting New Security Issues with thr Apache Struts
([original](https://struts.apache.org/security.html))
@@ -33,7 +29,7 @@ All mail sent to this address that does not relate to security problems in the A
```
Note that all networked servers are subject to denial of service attacks, and we cannot promise magic
workarounds to generic problems (such as a client streaming lots of data to your server or requesting
workarounds to generic problems (such as a client streaming lots of data to your server, or requesting
the same URL repeatedly). In general, our philosophy is to avoid any attacks that can cause the server
to consume resources in a non-linear relationship to the size of inputs.
@@ -42,85 +38,3 @@ The mailing address is: [security@struts.apache.org](mailto:security@struts.apac
[General network server security tips](http://httpd.apache.org/docs/trunk/misc/security_tips.html)
[The Apache Security Team](http://www.apache.org/security/)
## Before Reporting
Before sending a vulnerability report, run through the following checks. They exist to prevent duplicate reports, public disclosure of untriaged issues,
and reports for behavior that is already documented as insecure configuration.
### 1. Read this policy
Confirm:
- which Struts versions are currently supported (see [Supported Versions](#supported-versions)),
- where reports must be sent (see [Reporting New Security Issues](#reporting-new-security-issues-with-the-apache-struts)),
- which reports do not belong on the private security list.
### 2. Read the Struts security guidelines
Review the [Struts security guidance](https://struts.apache.org/security/) and determine whether the finding is already covered by documented secure
configuration or application guidance, including but not limited to:
- Config Browser Plugin exposure,
- direct JSP access,
- `devMode` is required to exploit the vulnerability,
- `@StrutsParameter` usage and parameter annotation requirements,
- unsafe setters or getters exposed to request parameters,
- use of incoming values in localization or forced OGNL evaluation,
- raw JSP EL expressions,
- custom error pages,
- Dynamic Method Invocation and Strict Method Invocation,
- accepted and excluded parameter patterns,
- Fetch Metadata, COOP, and COEP protections,
- OGNL sandboxing, allowlists, excluded classes/packages, and OGNL Guard settings.
If the behavior is caused by an application ignoring documented security guidance, that is not an Apache Struts framework vulnerability.
### 3. Check previously disclosed vulnerabilities
Compare the finding against already disclosed Struts vulnerabilities — affected versions, impact ratings, mitigations, and fixed versions:
- [Struts security information](https://struts.apache.org/security/)
- [Prior releases and vulnerability notes](https://struts.apache.org/releases.html#prior-releases)
- [Security Bulletins (S2 series)](https://cwiki.apache.org/confluence/display/WW/Security+Bulletins)
If the finding overlaps with a known vulnerability, link to the existing bulletin, advisory, CVE, or release notes instead of drafting a new report.
## Assessment
Before drafting a report, confirm:
1. Is the affected version supported?
2. Is the behavior in Apache Struts framework code, rather than only in an application using Struts?
3. Is it already documented as insecure configuration or unsupported usage?
4. Is it a duplicate of a previously disclosed vulnerability or Security Bulletin?
5. Can the impact be demonstrated with a minimal, self-contained reproduction?
Only proceed with a private report when these answers still point to a likely new vulnerability in the framework.
## Private Report Requirements
A useful private report includes:
- affected Struts version or version range,
- affected component or module,
- required application configuration, if any,
- minimal reproduction steps,
- expected behavior,
- actual behavior,
- demonstrated security impact,
- whether authentication or special privileges are required,
- proposed fix or mitigation, if known.
Do not speculate beyond what can be demonstrated. If severity is uncertain, say so explicitly.
## Report Quality Rules
- One vulnerability per report.
- Keep reproduction steps minimal and self-contained.
- Do not include unrelated findings.
- Do not publish exploit details or proof-of-concept code publicly before the Struts project has triaged the issue. **Pushing a PoC to a public GitHub
repository, gist, fork, or branch counts as public disclosure** — even a "test" or throwaway repo. Private repositories are acceptable for sharing a PoC,
but access must be granted individually to each PMC member who will triage the report.
- Do not send ordinary bugs, usage questions, or generic denial-of-service concerns to the private security list.
- If the issue is not a vulnerability in Apache Struts source code, use the appropriate public support or issue channel instead.
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-apps</artifactId>
+2 -2
View File
@@ -24,12 +24,12 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-apps</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-rest-showcase</artifactId>
<packaging>war</packaging>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<name>Struts 2 Rest Showcase Webapp</name>
<description>Struts 2 Rest Showcase Example</description>
+2 -2
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-apps</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-showcase</artifactId>
@@ -211,7 +211,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.6</version>
<version>3.5.5</version>
<configuration>
<includes>
<include>it.org.apache.struts2.showcase.*Test</include>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-project</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-bom</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-core</artifactId>
@@ -335,6 +335,7 @@ public final class StrutsConstants {
/**
* Controls FreeMarker whitespace stripping during template compilation.
* When enabled (default), removes indentation and trailing whitespace from lines containing only FTL tags.
* Automatically disabled when devMode is enabled.
*
* @since 7.2.0
*/
@@ -550,21 +551,6 @@ public final class StrutsConstants {
*/
public static final String STRUTS_PROXYSERVICE = "struts.proxyService";
/**
* The {@link org.apache.struts2.interceptor.parameter.ParameterAuthorizer} implementation class.
*
* @since 7.2.0
*/
public static final String STRUTS_PARAMETER_AUTHORIZER = "struts.parameterAuthorizer";
/**
* The {@link org.apache.struts2.interceptor.parameter.ParameterAllowlister} implementation class.
* Override to provide a custom allowlister for non-OGNL parameter targets.
*
* @since 7.2.0
*/
public static final String STRUTS_PARAMETER_ALLOWLISTER = "struts.parameterAllowlister";
/**
* Enables evaluation of OGNL expressions
*
@@ -733,7 +719,6 @@ public final class StrutsConstants {
public static final String STRUTS_CHAINING_COPY_ERRORS = "struts.chaining.copyErrors";
public static final String STRUTS_CHAINING_COPY_FIELD_ERRORS = "struts.chaining.copyFieldErrors";
public static final String STRUTS_CHAINING_COPY_MESSAGES = "struts.chaining.copyMessages";
public static final String STRUTS_CHAINING_REQUIRE_ANNOTATIONS = "struts.chaining.requireAnnotations";
public static final String STRUTS_OBJECT_FACTORY_CLASSLOADER = "struts.objectFactory.classloader";
/**
@@ -73,8 +73,6 @@ import org.apache.struts2.url.UrlDecoder;
import org.apache.struts2.url.UrlEncoder;
import org.apache.struts2.util.ContentTypeMatcher;
import org.apache.struts2.util.PatternMatcher;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.ProxyService;
import org.apache.struts2.util.TextParser;
import org.apache.struts2.util.ValueStackFactory;
@@ -448,8 +446,6 @@ public class StrutsBeanSelectionProvider extends AbstractBeanSelectionProvider {
alias(BeanInfoCacheFactory.class, StrutsConstants.STRUTS_OGNL_BEANINFO_CACHE_FACTORY, builder, props, Scope.SINGLETON);
alias(ProxyCacheFactory.class, StrutsConstants.STRUTS_PROXY_CACHE_FACTORY, builder, props, Scope.SINGLETON);
alias(ProxyService.class, StrutsConstants.STRUTS_PROXYSERVICE, builder, props, Scope.SINGLETON);
alias(ParameterAuthorizer.class, StrutsConstants.STRUTS_PARAMETER_AUTHORIZER, builder, props, Scope.SINGLETON);
alias(ParameterAllowlister.class, StrutsConstants.STRUTS_PARAMETER_ALLOWLISTER, builder, props, Scope.SINGLETON);
alias(SecurityMemberAccess.class, StrutsConstants.STRUTS_MEMBER_ACCESS, builder, props, Scope.PROTOTYPE);
alias(OgnlGuard.class, StrutsConstants.STRUTS_OGNL_GUARD, builder, props, Scope.SINGLETON);
@@ -92,10 +92,6 @@ import org.apache.struts2.ognl.SecurityMemberAccess;
import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
import org.apache.struts2.ognl.accessor.RootAccessor;
import org.apache.struts2.ognl.accessor.XWorkMethodAccessor;
import org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.StrutsProxyService;
import org.apache.struts2.util.OgnlTextParser;
import org.apache.struts2.util.PatternMatcher;
@@ -410,8 +406,6 @@ public class DefaultConfiguration implements Configuration {
.factory(BeanInfoCacheFactory.class, DefaultOgnlBeanInfoCacheFactory.class, Scope.SINGLETON)
.factory(ProxyCacheFactory.class, StrutsProxyCacheFactory.class, Scope.SINGLETON)
.factory(ProxyService.class, StrutsProxyService.class, Scope.SINGLETON)
.factory(ParameterAuthorizer.class, StrutsParameterAuthorizer.class, Scope.SINGLETON)
.factory(ParameterAllowlister.class, OgnlParameterAllowlister.class, Scope.SINGLETON)
.factory(OgnlUtil.class, Scope.SINGLETON)
.factory(SecurityMemberAccess.class, Scope.PROTOTYPE)
.factory(OgnlGuard.class, StrutsOgnlGuard.class, Scope.SINGLETON)
@@ -19,7 +19,6 @@
package org.apache.struts2.dispatcher.multipart;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.fileupload2.core.AbstractFileUpload;
import org.apache.commons.fileupload2.core.DiskFileItemFactory;
import org.apache.commons.fileupload2.core.FileUploadByteCountLimitException;
import org.apache.commons.fileupload2.core.FileUploadContentTypeException;
@@ -33,7 +32,6 @@ 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.StrutsException;
import org.apache.struts2.dispatcher.LocalizedMessage;
import org.apache.struts2.inject.Inject;
@@ -62,12 +60,6 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
private static final Logger LOG = LogManager.getLogger(AbstractMultiPartRequest.class);
/**
* Verified once per JVM: whether the commons-fileupload2 API on the classpath matches what
* Struts compiled against. Guards against a mismatched milestone resolving at runtime.
*/
private static volatile boolean fileUploadApiVerified;
/**
* Defines the internal buffer size used during streaming operations.
*/
@@ -219,7 +211,6 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
}
protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset charset, Path saveDir) {
ensureFileUploadApiVerified();
JakartaServletDiskFileUpload servletFileUpload = createJakartaFileUpload(charset, saveDir);
if (maxSize != null) {
@@ -237,48 +228,6 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
return servletFileUpload;
}
/**
* Verifies once per JVM that the commons-fileupload2 API on the classpath matches what Struts
* compiled against, failing fast with an actionable message instead of a deep-stack
* {@link NoSuchMethodError} when a mismatched milestone is resolved.
*/
private static void ensureFileUploadApiVerified() {
if (!fileUploadApiVerified) {
verifyFileUploadApi(JakartaServletDiskFileUpload.class);
fileUploadApiVerified = true;
}
}
/**
* Probes {@code uploadClass} for the size-limit setters Struts invokes in
* {@link #prepareServletFileUpload}. Package-private for testing.
*
* @param uploadClass the file upload class to verify
* @throws StrutsException if any required method is absent, indicating a binary-incompatible
* commons-fileupload2 version on the classpath
*/
static void verifyFileUploadApi(Class<?> uploadClass) {
for (String method : new String[]{"setMaxSize", "setMaxFileCount", "setMaxFileSize"}) {
try {
uploadClass.getMethod(method, long.class);
} catch (NoSuchMethodException e) {
throw new StrutsException(String.format(
"Incompatible Apache Commons FileUpload on the classpath: %s.%s(long) is missing. " +
"Detected commons-fileupload2-core version [%s] and commons-fileupload2-jakarta-servlet6 version [%s]. " +
"Align commons-fileupload2-core with commons-fileupload2-jakarta-servlet6 (use the same release for both).",
uploadClass.getName(), method,
implementationVersion(AbstractFileUpload.class),
implementationVersion(uploadClass)), e);
}
}
}
private static String implementationVersion(Class<?> clazz) {
Package pkg = clazz.getPackage();
String version = pkg != null ? pkg.getImplementationVersion() : null;
return version != null ? version : "unknown";
}
protected RequestContext createRequestContext(HttpServletRequest request) {
return new StrutsRequestContext(request);
}
@@ -292,7 +241,9 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
LocalizedMessage localizedMessage = new LocalizedMessage(this.getClass(),
STRUTS_MESSAGES_UPLOAD_ERROR_PARAMETER_TOO_LONG_KEY, null,
new Object[]{fieldName, maxStringLength, fieldValue.length()});
addErrorIfAbsent(localizedMessage);
if (!errors.contains(localizedMessage)) {
errors.add(localizedMessage);
}
return true;
}
return false;
@@ -327,17 +278,15 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
}
LocalizedMessage errorMessage = buildErrorMessage(exClass, e.getMessage(), args);
addErrorIfAbsent(errorMessage);
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
} catch (IOException e) {
LOG.warn("Unable to parse request", e);
LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{});
addErrorIfAbsent(errorMessage);
}
}
private void addErrorIfAbsent(LocalizedMessage errorMessage) {
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
}
}
@@ -500,7 +449,9 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
"Empty files are not allowed",
new Object[]{fileName, fieldName}
);
addErrorIfAbsent(errorMessage);
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
return true;
}
return false;
@@ -18,15 +18,12 @@
*/
package org.apache.struts2.interceptor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.Unchainable;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.result.ActionChainResult;
import org.apache.struts2.result.Result;
import org.apache.struts2.util.CompoundRoot;
@@ -35,16 +32,12 @@ import org.apache.struts2.util.TextParseUtil;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.reflection.ReflectionProvider;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
@@ -75,9 +68,6 @@ import java.util.Set;
* <li>struts.chaining.copyErrors - set to true to copy Action Errors</li>
* <li>struts.chaining.copyFieldErrors - set to true to copy Field Errors</li>
* <li>struts.chaining.copyMessages - set to true to copy Action Messages</li>
* <li>struts.chaining.requireAnnotations - set to true to only copy properties whose target
* Action member is annotated with {@code @StrutsParameter} (opt-in, default false). When the
* target cannot be introspected, no properties are copied (fail closed).</li>
* </ul>
*
* <p>
@@ -145,9 +135,6 @@ public class ChainingInterceptor extends AbstractInterceptor {
protected Collection<String> includes;
protected ReflectionProvider reflectionProvider;
private ProxyService proxyService;
private boolean requireAnnotations = false;
private transient ParameterAuthorizer parameterAuthorizer;
private transient OgnlUtil ognlUtil;
@Inject
public void setReflectionProvider(ReflectionProvider prov) {
@@ -159,21 +146,6 @@ public class ChainingInterceptor extends AbstractInterceptor {
this.proxyService = proxyService;
}
@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}
@Inject
public void setOgnlUtil(OgnlUtil ognlUtil) {
this.ognlUtil = ognlUtil;
}
@Inject(value = StrutsConstants.STRUTS_CHAINING_REQUIRE_ANNOTATIONS, required = false)
public void setRequireAnnotations(String requireAnnotations) {
this.requireAnnotations = BooleanUtils.toBoolean(requireAnnotations);
}
@Inject(value = StrutsConstants.STRUTS_CHAINING_COPY_ERRORS, required = false)
public void setCopyErrors(String copyErrors) {
this.copyErrors = "true".equalsIgnoreCase(copyErrors);
@@ -203,64 +175,15 @@ public class ChainingInterceptor extends AbstractInterceptor {
List<Object> list = prepareList(root);
Map<String, Object> ctxMap = invocation.getInvocationContext().getContextMap();
for (Object object : list) {
if (shouldCopy(object)) {
copyObjectToAction(object, invocation.getAction(), ctxMap);
}
}
}
private void copyObjectToAction(Object object, Object action, Map<String, Object> ctxMap) {
Class<?> editable = null;
if (proxyService.isProxy(action)) {
editable = proxyService.ultimateTargetClass(action);
}
Collection<String> copyExcludes = prepareExcludes();
if (requireAnnotations) {
Class<?> targetClass = editable != null ? editable : action.getClass();
BeanInfo beanInfo = getTargetBeanInfo(targetClass);
if (beanInfo == null) {
// Fail closed: cannot prove which properties are annotated, so copy nothing.
LOG.warn("Chaining: unable to introspect target [{}]; skipping property copy " +
"(struts.chaining.requireAnnotations enabled)", targetClass.getName());
return;
}
copyExcludes = excludeUnauthorizedProperties(copyExcludes, beanInfo, targetClass, action);
}
reflectionProvider.copy(object, action, ctxMap, copyExcludes, includes, editable);
}
/**
* Returns the excludes to use for the copy: the base excludes unioned with the names of all
* writable target properties that are not authorized by {@code @StrutsParameter}.
*/
private Collection<String> excludeUnauthorizedProperties(Collection<String> baseExcludes,
BeanInfo beanInfo, Class<?> targetClass, Object action) {
Set<String> merged = new HashSet<>();
if (baseExcludes != null) {
merged.addAll(baseExcludes);
}
for (PropertyDescriptor descriptor : beanInfo.getPropertyDescriptors()) {
if (descriptor.getWriteMethod() == null) {
if (!shouldCopy(object)) {
continue;
}
String name = descriptor.getName();
// target == action is deliberate: chaining copies onto the action object itself (not a
// ModelDriven model), so the authorizer's ModelDriven exemption must not apply here.
if (!parameterAuthorizer.isAuthorized(name, action, action)) {
LOG.warn("Chaining: property [{}] not copied to [{}] because it is not annotated with @StrutsParameter",
name, targetClass.getName());
merged.add(name);
Object action = invocation.getAction();
Class<?> editable = null;
if (proxyService.isProxy(action)) {
editable = proxyService.ultimateTargetClass(action);
}
}
return merged;
}
private BeanInfo getTargetBeanInfo(Class<?> targetClass) {
try {
return ognlUtil.getBeanInfo(targetClass);
} catch (IntrospectionException e) {
LOG.warn("Chaining: error introspecting target [{}] for @StrutsParameter enforcement", targetClass, e);
return null;
reflectionProvider.copy(object, action, ctxMap, prepareExcludes(), includes, editable);
}
}
@@ -26,8 +26,6 @@ import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.action.CookiesAware;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.security.AcceptedPatternsChecker;
import org.apache.struts2.security.ExcludedPatternsChecker;
import org.apache.struts2.util.TextParseUtil;
@@ -101,16 +99,8 @@ import java.util.Set;
*
* <ul>
* <li>
* populateCookieValueIntoStack(name, value, map, stack, action) - the preferred extension point
* since 7.2.0. The default implementation gates the cookie write through
* {@link org.apache.struts2.interceptor.parameter.ParameterAuthorizer} and primes the OGNL allowlist via
* {@link org.apache.struts2.interceptor.parameter.ParameterAllowlister} before delegating to the legacy
* 4-arg {@code populateCookieValueIntoStack}. Override here to customize the authorization behavior itself.
* </li>
* <li>
* populateCookieValueIntoStack(name, value, map, stack) - <em>deprecated since 7.2.0</em>. The legacy
* hook that performs the actual {@code stack.setValue}. Existing overrides continue to work and
* automatically receive only authorized cookies via the 5-arg default.
* populateCookieValueIntoStack - this method will decide if this cookie value is qualified
* to be populated into the value stack (hence into the action itself)
* </li>
* <li>
* injectIntoCookiesAwareAction - this method will inject selected cookies (as a java.util.Map)
@@ -197,8 +187,6 @@ public class CookieInterceptor extends AbstractInterceptor {
private ExcludedPatternsChecker excludedPatternsChecker;
private AcceptedPatternsChecker acceptedPatternsChecker;
private transient ParameterAuthorizer parameterAuthorizer;
private transient ParameterAllowlister parameterAllowlister;
@Inject
public void setExcludedPatternsChecker(ExcludedPatternsChecker excludedPatternsChecker) {
@@ -211,16 +199,6 @@ public class CookieInterceptor extends AbstractInterceptor {
this.acceptedPatternsChecker.setAcceptedPatterns(ACCEPTED_PATTERN);
}
@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}
@Inject
public void setParameterAllowlister(ParameterAllowlister parameterAllowlister) {
this.parameterAllowlister = parameterAllowlister;
}
/**
* @param cookiesName the <code>cookiesName</code> which if matched will allow the cookie
* to be injected into action, could be comma-separated string.
@@ -256,8 +234,6 @@ public class CookieInterceptor extends AbstractInterceptor {
public String intercept(ActionInvocation invocation) throws Exception {
LOG.debug("start interception");
final Object action = invocation.getAction();
// contains selected cookies
final Map<String, String> cookiesMap = new LinkedHashMap<>();
@@ -272,9 +248,9 @@ public class CookieInterceptor extends AbstractInterceptor {
if (isAcceptableName(name)) {
if (cookiesNameSet.contains("*")) {
LOG.debug("Contains cookie name [*] in configured cookies name set, cookie with name [{}] with value [{}] will be injected", name, value);
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
populateCookieValueIntoStack(name, value, cookiesMap, stack);
} else if (cookiesNameSet.contains(cookie.getName())) {
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
populateCookieValueIntoStack(name, value, cookiesMap, stack);
}
} else {
LOG.warn("Cookie name [{}] with value [{}] was rejected!", name, value);
@@ -283,7 +259,7 @@ public class CookieInterceptor extends AbstractInterceptor {
}
// inject the cookiesMap, even if we don't have any cookies
injectIntoCookiesAwareAction(action, cookiesMap);
injectIntoCookiesAwareAction(invocation.getAction(), cookiesMap);
return invocation.invoke();
}
@@ -338,30 +314,6 @@ public class CookieInterceptor extends AbstractInterceptor {
return false;
}
/**
* Authorizes the cookie against {@link ParameterAuthorizer}, primes OGNL allowlist for any nested path via
* {@link ParameterAllowlister}, then delegates to the legacy {@link #populateCookieValueIntoStack(String, String,
* Map, ValueStack)} hook so existing subclass overrides continue to participate. Override this method to customize
* the authorization behavior itself.
*
* @param cookieName cookie name (potentially an OGNL path; {@code ACCEPTED_PATTERN} restricts the character set)
* @param cookieValue cookie value
* @param cookiesMap map of cookies populated for {@link org.apache.struts2.action.CookiesAware}
* @param stack current request value stack
* @param action the action instance from {@link ActionInvocation#getAction()}; used for {@code @StrutsParameter} target resolution
* @since 7.2.0
*/
@SuppressWarnings("deprecation") // intentional: delegating to the deprecated 4-arg form is the contract that lets existing subclass overrides participate
protected void populateCookieValueIntoStack(String cookieName, String cookieValue, Map<String, String> cookiesMap, ValueStack stack, Object action) {
Object target = parameterAuthorizer.resolveTarget(action);
if (!parameterAuthorizer.isAuthorized(cookieName, target, action)) {
LOG.debug("Cookie [{}] rejected by @StrutsParameter authorization on target [{}]", cookieName, target.getClass().getSimpleName());
return;
}
parameterAllowlister.primeAllowlistForPath(cookieName, target);
populateCookieValueIntoStack(cookieName, cookieValue, cookiesMap, stack);
}
/**
* Hook that populate cookie value into value stack (hence the action)
* if the criteria is satisfied (if the cookie value matches with those configured).
@@ -370,12 +322,7 @@ public class CookieInterceptor extends AbstractInterceptor {
* @param cookieValue cookie value
* @param cookiesMap map of cookies
* @param stack value stack
* @deprecated since 7.2.0. Override
* {@link #populateCookieValueIntoStack(String, String, Map, ValueStack, Object)} instead so cookie writes are
* authorized by {@link ParameterAuthorizer}. The default 5-arg implementation calls this method after the
* authorization gate, so existing overrides continue to receive only authorized cookies.
*/
@Deprecated(since = "7.2.0")
protected void populateCookieValueIntoStack(String cookieName, String cookieValue, Map<String, String> cookiesMap, ValueStack stack) {
if (cookiesValueSet.isEmpty() || cookiesValueSet.contains("*")) {
// If the interceptor is configured to accept any cookie value
@@ -90,8 +90,7 @@ public class HttpMethodInterceptor extends AbstractInterceptor {
invocation.getProxy().getMethod(), AllowedHttpMethod.class.getSimpleName(), request.getMethod());
return doIntercept(invocation, method);
}
}
if (AnnotationUtils.isAnnotatedBy(action.getClass(), HTTP_METHOD_ANNOTATIONS)) {
} else if (AnnotationUtils.isAnnotatedBy(action.getClass(), HTTP_METHOD_ANNOTATIONS)) {
LOG.debug("Action: {} annotated with: {}, checking if request: {} meets allowed methods!",
action, AllowedHttpMethod.class.getSimpleName(), request.getMethod());
return doIntercept(invocation, action.getClass());
@@ -1,192 +0,0 @@
/*
* 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.interceptor.parameter;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.ThreadAllowlist;
import org.apache.struts2.util.ProxyService;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Optional;
import static org.apache.commons.lang3.StringUtils.indexOfAny;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;
/**
* Default {@link ParameterAllowlister}. Registers the root property's class (and generic type args for {@code depth >= 2})
* into the OGNL {@link ThreadAllowlist} so OGNL may introspect and traverse a nested path on the value stack. Logic is
* extracted verbatim from {@code ParametersInterceptor.performOgnlAllowlisting} so the OGNL parameter and cookie
* channels share a single implementation.
*
* <p>No-ops when:
* <ul>
* <li>{@code paramDepth == 0} — shallow setter; OGNL does not need to traverse</li>
* <li>the root property has no {@code @StrutsParameter} annotation reachable via {@link java.beans.PropertyDescriptor}
* or as a public field (e.g. a {@code ModelDriven} model whose properties are not individually annotated). A
* {@code LOG.debug} surfaces this case so the gap between authorization and OGNL traversal is observable.</li>
* </ul>
*
* @since 7.2.0
*/
public class OgnlParameterAllowlister implements ParameterAllowlister {
private static final Logger LOG = LogManager.getLogger(OgnlParameterAllowlister.class);
private OgnlUtil ognlUtil;
private ProxyService proxyService;
private ThreadAllowlist threadAllowlist;
@Inject
public void setOgnlUtil(OgnlUtil ognlUtil) {
this.ognlUtil = ognlUtil;
}
@Inject
public void setProxyService(ProxyService proxyService) {
this.proxyService = proxyService;
}
@Inject
public void setThreadAllowlist(ThreadAllowlist threadAllowlist) {
this.threadAllowlist = threadAllowlist;
}
@Override
public void primeAllowlistForPath(String parameterName, Object target) {
if (parameterName == null || parameterName.isEmpty() || target == null) {
return;
}
long paramDepth = parameterName.codePoints().mapToObj(c -> (char) c).filter(NESTING_CHARS::contains).count();
if (paramDepth == 0) {
return;
}
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);
if (allowlistViaPropertyDescriptor(target, normalisedRootProperty, paramDepth)) {
return;
}
if (allowlistViaPublicField(target, normalisedRootProperty, paramDepth)) {
return;
}
// Authorization passed but no @StrutsParameter on the root property — e.g. ModelDriven model with no
// per-property annotations. OGNL won't be able to walk this nested path; surface the gap in logs.
LOG.debug("Parameter [{}] authorized but no @StrutsParameter on root property [{}] of [{}]; "
+ "OGNL allowlist not primed and nested traversal may be blocked",
parameterName, normalisedRootProperty, ultimateClass(target).getSimpleName());
}
private boolean allowlistViaPropertyDescriptor(Object target, String rootProperty, long paramDepth) {
BeanInfo beanInfo = getBeanInfo(target);
if (beanInfo == null) {
return false;
}
Optional<PropertyDescriptor> propDescOpt = Arrays.stream(beanInfo.getPropertyDescriptors())
.filter(desc -> desc.getName().equals(rootProperty)).findFirst();
if (propDescOpt.isEmpty()) {
return false;
}
PropertyDescriptor propDesc = propDescOpt.get();
Method relevantMethod = propDesc.getReadMethod();
if (relevantMethod == null || getPermittedInjectionDepth(relevantMethod) < paramDepth) {
return false;
}
allowlistClass(propDesc.getPropertyType());
if (paramDepth >= 2) {
allowlistParameterizedTypeArg(relevantMethod.getGenericReturnType());
}
return true;
}
private boolean allowlistViaPublicField(Object target, String rootProperty, long paramDepth) {
Class<?> targetClass = ultimateClass(target);
Field field;
try {
field = targetClass.getDeclaredField(rootProperty);
} catch (NoSuchFieldException e) {
return false;
}
if (!Modifier.isPublic(field.getModifiers()) || getPermittedInjectionDepth(field) < paramDepth) {
return false;
}
allowlistClass(field.getType());
if (paramDepth >= 2) {
allowlistParameterizedTypeArg(field.getGenericType());
}
return true;
}
private void allowlistClass(Class<?> clazz) {
threadAllowlist.allowClassHierarchy(clazz);
}
private void allowlistParameterizedTypeArg(Type genericType) {
if (!(genericType instanceof ParameterizedType pType)) {
return;
}
Type[] paramTypes = pType.getActualTypeArguments();
allowlistParamType(paramTypes[0]);
if (paramTypes.length > 1) {
allowlistParamType(paramTypes[1]);
}
}
private void allowlistParamType(Type paramType) {
if (paramType instanceof Class<?> clazz) {
allowlistClass(clazz);
}
}
private int getPermittedInjectionDepth(AnnotatedElement element) {
StrutsParameter annotation = element.getAnnotation(StrutsParameter.class);
return annotation == null ? -1 : annotation.depth();
}
private Class<?> ultimateClass(Object target) {
if (proxyService.isProxy(target)) {
return proxyService.ultimateTargetClass(target);
}
return target.getClass();
}
private BeanInfo getBeanInfo(Object target) {
Class<?> targetClass = ultimateClass(target);
try {
return ognlUtil.getBeanInfo(targetClass);
} catch (IntrospectionException e) {
LOG.warn("Error introspecting target {} for OGNL allowlisting", targetClass, e);
return null;
}
}
}
@@ -1,44 +0,0 @@
/*
* 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.interceptor.parameter;
/**
* Primes channel-specific runtime state required for an already-authorized parameter path to be walked by the
* value-stack — for example, registering the path's classes into the OGNL {@link org.apache.struts2.ognl.ThreadAllowlist}
* so OGNL may traverse them. Separated from {@link ParameterAuthorizer} so the authorization decision can remain
* side-effect-free and reusable from non-OGNL channels (Jackson, Juneau).
*
* <p>Implementations MUST NOT repeat the authorization decision — that is owned by
* {@link ParameterAuthorizer#isAuthorized}. A no-op return (e.g. shallow paths, unannotated root) means "no priming
* needed or possible" and never "rejected": callers must not treat the absence of priming as a negative authorization
* signal.</p>
*
* @since 7.2.0
*/
public interface ParameterAllowlister {
/**
* Primes the channel-specific allowlist for an authorized parameter path. Side-effect-only; no return value
* because a no-op is a valid outcome (see class-level javadoc).
*
* @param parameterName the parameter name (e.g. {@code "user.role"}, {@code "items[0].name"})
* @param target the object receiving the parameter value (the action, or the model for ModelDriven actions)
*/
void primeAllowlistForPath(String parameterName, Object target);
}
@@ -1,144 +0,0 @@
/*
* 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.interceptor.parameter;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;
/**
* ThreadLocal holder for per-request parameter authorization state, used by deserializer-level
* authorization (e.g. the REST plugin's {@code ContentTypeInterceptor}). All state — the
* {@link ParameterAuthorizer}, the target, the action, and the current property-path stack — is
* bound by input-channel interceptors before invoking the deserializer, and unbound in a
* {@code finally} block afterwards.
*
* <p>Implementations that consult this context (e.g. {@code AuthorizingSettableBeanProperty}) call
* {@link #isActive()} to decide whether to enforce authorization at all — when no context is bound
* (default config, {@code requireAnnotations=false}), they short-circuit to the delegate behavior.</p>
*
* @since 7.2.0
*/
public final class ParameterAuthorizationContext {
private static final ThreadLocal<State> STATE = new ThreadLocal<>();
private static final ThreadLocal<Deque<String>> PATH_STACK = ThreadLocal.withInitial(ArrayDeque::new);
private ParameterAuthorizationContext() {
// utility
}
/**
* Binds an authorizer, target, and action to the current thread. {@code target} is the object
* being populated — typically the action itself, or the model object for {@code ModelDriven}
* actions (the same contract as {@link ParameterAuthorizer#isAuthorized}). {@code action} is
* always the action instance. A subsequent call without an intervening {@link #unbind()} replaces
* the prior state without resetting the path stack.
*
* @param authorizer the authorizer to use for this request; must not be {@code null}
* @param target the object being populated (action or model)
* @param action the action instance
*/
public static void bind(ParameterAuthorizer authorizer, Object target, Object action) {
Objects.requireNonNull(authorizer, "authorizer");
STATE.set(new State(authorizer, target, action));
}
/**
* Removes the bound authorizer state and clears the path stack for the current thread.
* Safe to call even when no context has been bound.
*/
public static void unbind() {
STATE.remove();
PATH_STACK.remove();
}
/**
* Returns {@code true} if an authorizer has been bound on the current thread via {@link #bind}.
*/
public static boolean isActive() {
return STATE.get() != null;
}
/**
* Authorizes a parameter at the given path against the bound authorizer. Returns {@code true}
* when no context is bound — callers that don't want enforcement at all should not bind context
* in the first place; this default keeps wrapping deserializers safe for non-authorized requests.
*/
public static boolean isAuthorized(String parameterPath) {
State state = STATE.get();
if (state == null) {
return true;
}
return state.authorizer.isAuthorized(parameterPath, state.target, state.action);
}
/**
* Pushes the full cumulative path prefix onto the stack. Subsequent {@link #pathFor(String)}
* calls will append {@code name} to this prefix. Callers building a collection-element prefix
* (e.g. {@code items[0]}) must pass the full string including the suffix.
*
* @param cumulativePath the full path prefix to push (e.g. {@code "address"} or {@code "items[0]"})
*/
public static void pushPath(String cumulativePath) {
PATH_STACK.get().push(cumulativePath);
}
/**
* Pops the top path prefix from the stack. Has no effect if the stack is empty.
*/
public static void popPath() {
Deque<String> stack = PATH_STACK.get();
if (!stack.isEmpty()) {
stack.pop();
}
}
/**
* @return the current top-of-stack path prefix, or empty string if none
*/
public static String currentPathPrefix() {
Deque<String> stack = PATH_STACK.get();
if (stack.isEmpty()) {
return "";
}
return stack.peek();
}
/**
* Builds the full path for a property at the current nesting level: {@code prefix.propertyName}
* (or just {@code propertyName} when at the root).
*/
public static String pathFor(String propertyName) {
String prefix = currentPathPrefix();
return prefix.isEmpty() ? propertyName : prefix + "." + propertyName;
}
private static final class State {
final ParameterAuthorizer authorizer;
final Object target;
final Object action;
State(ParameterAuthorizer authorizer, Object target, Object action) {
this.authorizer = authorizer;
this.target = target;
this.action = action;
}
}
}
@@ -1,68 +0,0 @@
/*
* 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.interceptor.parameter;
/**
* Service for determining whether a given parameter name is authorized for injection into a target object, based on
* {@link StrutsParameter} annotation presence and depth.
*
* <p>This service extracts the authorization logic from {@link ParametersInterceptor} so that it can be reused by other
* input channels (e.g. JSON plugin, REST plugin) that also need to enforce {@code @StrutsParameter} rules.</p>
*
* <p>Implementations must NOT perform OGNL ThreadAllowlist side effects — those remain specific to
* {@link ParametersInterceptor}.</p>
*
* @since 7.2.0
*/
public interface ParameterAuthorizer {
/**
* Determines whether a parameter with the given name is authorized for injection into the given target object.
*
* <p>When {@code struts.parameters.requireAnnotations} is {@code false}, this method always returns {@code true}
* for backward compatibility.</p>
*
* @param parameterName the parameter name (e.g. "name", "address.city", "items[0].name")
* @param target the object receiving the parameter value (the action, or the model for ModelDriven actions)
* @param action the action instance; used to detect ModelDriven exemption (when {@code target != action},
* the target is the model and is exempt from annotation requirements)
* @return {@code true} if the parameter is authorized for injection, {@code false} otherwise
*/
boolean isAuthorized(String parameterName, Object target, Object action);
/**
* Resolves the target object whose annotations should be checked for authorization.
* For {@link org.apache.struts2.ModelDriven} actions, the default implementation returns the action itself;
* the production implementation ({@link StrutsParameterAuthorizer}) overrides this to return the model from
* the value stack.
*
* <p>Callers that need both authorization checks AND the resolved target (e.g. for downstream OGNL allowlisting)
* should call this once and reuse the result.</p>
*
* <p>This is a {@code default} method to preserve the interface as a functional interface (SAM) for
* lambda-based test stubs.</p>
*
* @param action the action instance
* @return the resolved target — either the action or its model
* @since 7.2.0
*/
default Object resolveTarget(Object action) {
return action;
}
}
@@ -23,6 +23,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.action.NoParameters;
import org.apache.struts2.action.ParameterNameAware;
@@ -65,7 +66,10 @@ import java.util.regex.Pattern;
import static java.lang.String.format;
import static java.util.Collections.unmodifiableSet;
import static java.util.stream.Collectors.joining;
import static org.apache.commons.lang3.StringUtils.indexOfAny;
import static org.apache.commons.lang3.StringUtils.normalizeSpace;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;
import static org.apache.struts2.util.DebugUtils.logWarningForFirstOccurrence;
import static org.apache.struts2.util.DebugUtils.notifyDeveloperOfError;
@@ -96,8 +100,6 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
private AcceptedPatternsChecker acceptedPatterns;
private Set<Pattern> excludedValuePatterns = null;
private Set<Pattern> acceptedValuePatterns = null;
private ParameterAuthorizer parameterAuthorizer;
private transient ParameterAllowlister parameterAllowlister;
@Inject
public void setValueStackFactory(ValueStackFactory valueStackFactory) {
@@ -119,16 +121,6 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
this.proxyService = proxyService;
}
@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}
@Inject
public void setParameterAllowlister(ParameterAllowlister parameterAllowlister) {
this.parameterAllowlister = parameterAllowlister;
}
@Inject(StrutsConstants.STRUTS_DEVMODE)
public void setDevMode(String mode) {
this.devMode = BooleanUtils.toBoolean(mode);
@@ -360,9 +352,6 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
* Checks if the Action class member corresponding to a parameter is appropriately annotated with
* {@link StrutsParameter} and OGNL allowlists any necessary classes.
* <p>
* Authorization is delegated to {@link ParameterAuthorizer}. If authorized, OGNL allowlisting is performed as a
* second pass (this is specific to the OGNL-based parameter injection path and not shared with other input channels).
* <p>
* Note that this logic relies on the use of {@link DefaultAcceptedPatternsChecker#NESTING_CHARS} and may also
* be adversely impacted by the use of custom OGNL property accessors.
*/
@@ -371,15 +360,23 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
return true;
}
Object target = parameterAuthorizer.resolveTarget(action);
long paramDepth = name.codePoints().mapToObj(c -> (char) c).filter(NESTING_CHARS::contains).count();
// Delegate authorization check to shared ParameterAuthorizer (no OGNL side effects)
if (!parameterAuthorizer.isAuthorized(name, target, action)) {
return false;
if (action instanceof ModelDriven<?> && !ActionContext.getContext().getValueStack().peek().equals(action)) {
LOG.debug("Model driven Action detected, exempting from @StrutsParameter annotation requirement");
return true;
}
parameterAllowlister.primeAllowlistForPath(name, target);
return true;
if (requireAnnotationsTransitionMode && paramDepth == 0) {
LOG.debug("Annotation transition mode enabled, exempting non-nested parameter [{}] from @StrutsParameter annotation requirement", name);
return true;
}
int nestingIndex = indexOfAny(name, NESTING_CHARS_STR);
String rootProperty = nestingIndex == -1 ? name : name.substring(0, nestingIndex);
String normalisedRootProperty = Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);
return hasValidAnnotatedMember(normalisedRootProperty, action, paramDepth);
}
/**
@@ -1,244 +0,0 @@
/*
* 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.interceptor.parameter;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.util.ProxyService;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Optional;
import static java.lang.String.format;
import static org.apache.commons.lang3.StringUtils.indexOfAny;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;
import static org.apache.struts2.util.DebugUtils.notifyDeveloperOfError;
/**
* Default implementation of {@link ParameterAuthorizer} that checks {@link StrutsParameter} annotations on the target
* object's members to determine whether a parameter is authorized for injection.
*
* <p>This implementation extracts the authorization logic from {@link ParametersInterceptor} so that it can be shared
* with other input channels (JSON plugin, REST plugin) without duplicating code.</p>
*
* <p>Unlike {@link ParametersInterceptor}, this implementation does NOT perform OGNL ThreadAllowlist side effects.
* Those remain specific to the OGNL-based parameter injection path.</p>
*
* @since 7.2.0
*/
public class StrutsParameterAuthorizer implements ParameterAuthorizer {
private static final Logger LOG = LogManager.getLogger(StrutsParameterAuthorizer.class);
private boolean requireAnnotations = false;
private boolean requireAnnotationsTransitionMode = false;
private boolean devMode = false;
private OgnlUtil ognlUtil;
private ProxyService proxyService;
@Inject
public void setOgnlUtil(OgnlUtil ognlUtil) {
this.ognlUtil = ognlUtil;
}
@Inject
public void setProxyService(ProxyService proxyService) {
this.proxyService = proxyService;
}
@Inject(StrutsConstants.STRUTS_DEVMODE)
public void setDevMode(String mode) {
this.devMode = BooleanUtils.toBoolean(mode);
}
@Inject(value = StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS, required = false)
public void setRequireAnnotations(String requireAnnotations) {
this.requireAnnotations = BooleanUtils.toBoolean(requireAnnotations);
}
@Inject(value = StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS_TRANSITION, required = false)
public void setRequireAnnotationsTransitionMode(String transitionMode) {
this.requireAnnotationsTransitionMode = BooleanUtils.toBoolean(transitionMode);
}
@Override
public Object resolveTarget(Object action) {
if (action instanceof ModelDriven<?>) {
Object stackTop = ActionContext.getContext().getValueStack().peek();
if (!stackTop.equals(action)) {
return stackTop;
}
}
return action;
}
@Override
public boolean isAuthorized(String parameterName, Object target, Object action) {
if (parameterName == null || parameterName.isEmpty()) {
return false;
}
if (!requireAnnotations) {
return true;
}
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;
}
// Transition mode: depth-0 (non-nested) parameters are exempt
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);
return hasValidAnnotatedMember(normalisedRootProperty, target, paramDepth);
}
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);
BeanInfo beanInfo = getBeanInfo(target);
if (beanInfo == null) {
return hasValidAnnotatedField(target, rootProperty, paramDepth);
}
Optional<PropertyDescriptor> propDescOpt = Arrays.stream(beanInfo.getPropertyDescriptors())
.filter(desc -> desc.getName().equals(rootProperty)).findFirst();
if (propDescOpt.isEmpty()) {
return hasValidAnnotatedField(target, rootProperty, paramDepth);
}
if (hasValidAnnotatedPropertyDescriptor(target, propDescOpt.get(), paramDepth)) {
return true;
}
return hasValidAnnotatedField(target, rootProperty, paramDepth);
}
protected boolean hasValidAnnotatedPropertyDescriptor(Object target, PropertyDescriptor propDesc, long paramDepth) {
Class<?> targetClass = ultimateClass(target);
Method relevantMethod = paramDepth == 0 ? propDesc.getWriteMethod() : propDesc.getReadMethod();
if (relevantMethod == null) {
return false;
}
if (getPermittedInjectionDepth(relevantMethod) < paramDepth) {
String logMessage = format(
"Parameter injection for method [%s] on target [%s] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.",
relevantMethod.getName(),
relevantMethod.getDeclaringClass().getName());
if (devMode) {
notifyDeveloperOfError(LOG, target, logMessage);
} else {
LOG.debug(logMessage);
}
return false;
}
LOG.debug("Success: Matching annotated method [{}] found for property [{}] of depth [{}] on target [{}]",
relevantMethod.getName(), propDesc.getName(), paramDepth, targetClass.getSimpleName());
return true;
}
protected boolean hasValidAnnotatedField(Object target, String fieldName, long paramDepth) {
Class<?> targetClass = ultimateClass(target);
LOG.debug("No matching annotated method found for property [{}] of depth [{}] on target [{}], now also checking for public field",
fieldName, paramDepth, targetClass.getSimpleName());
Field field;
try {
field = targetClass.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
LOG.debug("Matching field for property [{}] not found on target [{}]", fieldName, targetClass.getSimpleName());
return false;
}
if (!Modifier.isPublic(field.getModifiers())) {
LOG.debug("Matching field [{}] is not public on target [{}]", field.getName(), targetClass.getSimpleName());
return false;
}
if (getPermittedInjectionDepth(field) < paramDepth) {
String logMessage = format(
"Parameter injection for field [%s] on target [%s] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.",
field.getName(),
targetClass.getName());
if (devMode) {
notifyDeveloperOfError(LOG, target, logMessage);
} else {
LOG.debug(logMessage);
}
return false;
}
LOG.debug("Success: Matching annotated public field [{}] found for property of depth [{}] on target [{}]",
field.getName(), paramDepth, targetClass.getSimpleName());
return true;
}
protected int getPermittedInjectionDepth(AnnotatedElement element) {
StrutsParameter annotation = getParameterAnnotation(element);
if (annotation == null) {
return -1;
}
return annotation.depth();
}
protected StrutsParameter getParameterAnnotation(AnnotatedElement element) {
return element.getAnnotation(StrutsParameter.class);
}
protected Class<?> ultimateClass(Object target) {
if (proxyService.isProxy(target)) {
return proxyService.ultimateTargetClass(target);
}
return target.getClass();
}
protected BeanInfo getBeanInfo(Object target) {
Class<?> targetClass = ultimateClass(target);
try {
return ognlUtil.getBeanInfo(targetClass);
} catch (IntrospectionException e) {
LOG.warn("Error introspecting target {} for parameter authorization", targetClass, e);
return null;
}
}
}
@@ -18,10 +18,9 @@
*/
package org.apache.struts2.ognl;
import ognl.OgnlContext;
import org.apache.struts2.conversion.NullHandler;
public class OgnlNullHandlerWrapper implements ognl.NullHandler {
public class OgnlNullHandlerWrapper implements ognl.NullHandler<StrutsContext> {
private final NullHandler wrapped;
@@ -30,13 +29,13 @@ public class OgnlNullHandlerWrapper implements ognl.NullHandler {
}
@Override
public Object nullMethodResult(OgnlContext context, Object target,
public Object nullMethodResult(StrutsContext context, Object target,
String methodName, Object[] args) {
return wrapped.nullMethodResult(context, target, methodName, args);
}
@Override
public Object nullPropertyValue(OgnlContext context, Object target, Object property) {
public Object nullPropertyValue(StrutsContext context, Object target, Object property) {
return wrapped.nullPropertyValue(context, target, property);
}
@@ -29,6 +29,7 @@ import ognl.Ognl;
public class OgnlReflectionContextFactory implements ReflectionContextFactory {
@Override
@SuppressWarnings({"rawtypes", "unchecked"})
public OgnlContext createDefaultContext(Object root) {
return Ognl.createDefaultContext(root);
}
@@ -18,7 +18,6 @@
*/
package org.apache.struts2.ognl;
import ognl.OgnlContext;
import org.apache.struts2.conversion.TypeConverter;
import java.lang.reflect.Member;
@@ -26,7 +25,7 @@ import java.lang.reflect.Member;
/**
* Wraps an XWork type conversion class for as an OGNL TypeConverter
*/
public class OgnlTypeConverterWrapper implements ognl.TypeConverter {
public class OgnlTypeConverterWrapper implements ognl.TypeConverter<StrutsContext> {
private final TypeConverter typeConverter;
@@ -38,7 +37,7 @@ public class OgnlTypeConverterWrapper implements ognl.TypeConverter {
}
@Override
public Object convertValue(OgnlContext context, Object target, Member member, String propertyName, Object value, Class<?> toType) {
public Object convertValue(StrutsContext context, Object target, Member member, String propertyName, Object value, Class<?> toType) {
return typeConverter.convertValue(context, target, member, propertyName, value, toType);
}
@@ -61,7 +61,7 @@ public class OgnlUtil {
private final OgnlCache<String, Object> expressionCache;
private final OgnlCache<Class<?>, BeanInfo> beanInfoCache;
private TypeConverter defaultConverter;
private TypeConverter<StrutsContext> defaultConverter;
private final OgnlGuard ognlGuard;
private boolean devMode;
@@ -211,14 +211,14 @@ public class OgnlUtil {
* @return an OgnlContext instance
* @since 7.2.0
*/
private OgnlContext ensureOgnlContext(Map<String, Object> context) {
if (context instanceof OgnlContext ognlContext) {
return ognlContext;
private StrutsContext ensureOgnlContext(Map<String, Object> context) {
if (context instanceof StrutsContext strutsContext) {
return strutsContext;
}
// Create a new OgnlContext and copy the Map contents
OgnlContext ognlContext = createDefaultContext(null);
ognlContext.putAll(context);
return ognlContext;
// Create a new StrutsContext and copy the Map contents
StrutsContext strutsContext = createDefaultContext(null);
strutsContext.putAll(context);
return strutsContext;
}
/**
@@ -247,9 +247,9 @@ public class OgnlUtil {
return;
}
OgnlContext ognlContext = ensureOgnlContext(context);
StrutsContext strutsContext = ensureOgnlContext(context);
try {
withRoot(ognlContext, o, () -> {
withRoot(strutsContext, o, () -> {
for (Map.Entry<String, ?> entry : props.entrySet()) {
String expression = entry.getKey();
internalSetProperty(expression, entry.getValue(), o, context, throwPropertyExceptions);
@@ -309,9 +309,9 @@ public class OgnlUtil {
* problems setting the property
*/
public void setProperty(String name, Object value, Object o, Map<String, Object> context, boolean throwPropertyExceptions) {
OgnlContext ognlContext = ensureOgnlContext(context);
StrutsContext strutsContext = ensureOgnlContext(context);
try {
withRoot(ognlContext, o, () -> internalSetProperty(name, value, o, context, throwPropertyExceptions));
withRoot(strutsContext, o, () -> internalSetProperty(name, value, o, context, throwPropertyExceptions));
} catch (OgnlException e) {
// Should never happen as internalSetProperty catches OgnlException
throw new IllegalStateException("Unexpected OgnlException in setProperty", e);
@@ -424,7 +424,7 @@ public class OgnlUtil {
for (TreeValidator validator : treeValidators) {
validator.validate(tree, checkContext);
}
OgnlContext ognlContext = (OgnlContext) context;
StrutsContext ognlContext = (StrutsContext) context;
withRoot(ognlContext, root, () -> Ognl.setValue(tree, ognlContext, root, value));
}
@@ -434,7 +434,7 @@ public class OgnlUtil {
for (TreeValidator validator : treeValidators) {
validator.validate(tree, checkContext);
}
OgnlContext ognlContext = (OgnlContext) context;
StrutsContext ognlContext = (StrutsContext) context;
return withRoot(ognlContext, root, () -> (T) Ognl.getValue(tree, ognlContext, root, resultType));
}
@@ -548,8 +548,8 @@ public class OgnlUtil {
return;
}
final Map<String, Object> contextFrom = createDefaultContext(from);
final Map<String, Object> contextTo = createDefaultContext(to);
final StrutsContext contextFrom = createDefaultContext(from);
final StrutsContext contextTo = createDefaultContext(to);
PropertyDescriptor[] fromPds;
PropertyDescriptor[] toPds;
@@ -654,7 +654,7 @@ public class OgnlUtil {
*/
public Map<String, Object> getBeanMap(final Object source) throws IntrospectionException, OgnlException {
Map<String, Object> beanMap = new HashMap<>();
final Map<String, Object> sourceMap = createDefaultContext(source);
final StrutsContext sourceMap = createDefaultContext(source);
PropertyDescriptor[] propertyDescriptors = getPropertyDescriptors(source);
for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
final String propertyName = propertyDescriptor.getDisplayName();
@@ -724,18 +724,21 @@ public class OgnlUtil {
}
}
protected OgnlContext createDefaultContext(Object root) {
protected StrutsContext createDefaultContext(Object root) {
return createDefaultContext(root, null);
}
protected OgnlContext createDefaultContext(Object root, ClassResolver resolver) {
protected StrutsContext createDefaultContext(Object root, ClassResolver<StrutsContext> resolver) {
if (resolver == null) {
resolver = container.getInstance(RootAccessor.class);
if (resolver == null) {
throw new IllegalStateException("Cannot find ClassResolver");
}
}
return Ognl.createDefaultContext(root, container.getInstance(SecurityMemberAccess.class), resolver, defaultConverter);
StrutsContext context = new StrutsContext(
container.getInstance(SecurityMemberAccess.class), resolver, defaultConverter);
context.withRoot(root);
return context;
}
@FunctionalInterface
@@ -762,13 +765,13 @@ public class OgnlUtil {
* @param action the action to execute
* @throws OgnlException if the action throws an OgnlException
*/
private void withRoot(OgnlContext context, Object root, OgnlAction action) throws OgnlException {
Object oldRoot = Ognl.getRoot(context);
private void withRoot(StrutsContext context, Object root, OgnlAction action) throws OgnlException {
Object oldRoot = context.getRoot();
try {
Ognl.setRoot(context, root);
context.withRoot(root);
action.run();
} finally {
Ognl.setRoot(context, oldRoot);
context.withRoot(oldRoot);
}
}
@@ -783,13 +786,13 @@ public class OgnlUtil {
* @return the result of the supplier
* @throws OgnlException if the supplier throws an OgnlException
*/
private <T> T withRoot(OgnlContext context, Object root, OgnlSupplier<T> supplier) throws OgnlException {
Object oldRoot = Ognl.getRoot(context);
private <T> T withRoot(StrutsContext context, Object root, OgnlSupplier<T> supplier) throws OgnlException {
Object oldRoot = context.getRoot();
try {
Ognl.setRoot(context, root);
context.withRoot(root);
return supplier.get();
} finally {
Ognl.setRoot(context, oldRoot);
context.withRoot(oldRoot);
}
}
}
@@ -31,8 +31,6 @@ import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.MethodFailedException;
import ognl.NoSuchPropertyException;
import ognl.Ognl;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
@@ -68,7 +66,7 @@ public class OgnlValueStack implements Serializable, ValueStack, ClearableValueS
private static final String MAP_IDENTIFIER_KEY = "org.apache.struts2.util.OgnlValueStack.MAP_IDENTIFIER_KEY";
protected CompoundRoot root;
protected transient Map<String, Object> context;
protected transient StrutsContext context;
protected Class defaultType;
protected Map<Object, Object> overrides;
protected transient OgnlUtil ognlUtil;
@@ -121,12 +119,12 @@ public class OgnlValueStack implements Serializable, ValueStack, ClearableValueS
protected void setRoot(XWorkConverter xworkConverter, RootAccessor accessor, CompoundRoot compoundRoot, SecurityMemberAccess securityMemberAccess) {
this.root = compoundRoot;
this.securityMemberAccess = securityMemberAccess;
OgnlContext ognlContext = Ognl.createDefaultContext(this.root, securityMemberAccess, accessor, new OgnlTypeConverterWrapper(xworkConverter));
this.context = ognlContext;
this.context = new StrutsContext(securityMemberAccess, accessor, new OgnlTypeConverterWrapper(xworkConverter));
this.context.withRoot(this.root);
this.converter = xworkConverter;
context.put(VALUE_STACK, this);
ognlContext.setTraceEvaluations(false);
ognlContext.setKeepLastEvaluation(false);
context.setTraceEvaluations(false);
context.setKeepLastEvaluation(false);
}
@Inject(StrutsConstants.STRUTS_DEVMODE)
@@ -508,9 +506,7 @@ public class OgnlValueStack implements Serializable, ValueStack, ClearableValueS
@Override
public void clearContextValues() {
//this is an OGNL ValueStack so the context will be an OgnlContext
//it would be better to make context of type OgnlContext
((OgnlContext) context).getValues().clear();
context.getValues().clear();
}
@Override
@@ -54,6 +54,7 @@ public class OgnlValueStackFactory implements ValueStackFactory {
}
@Inject
@SuppressWarnings({"rawtypes", "unchecked"})
protected void setCompoundRootAccessor(RootAccessor compoundRootAccessor) {
this.compoundRootAccessor = compoundRootAccessor;
OgnlRuntime.setPropertyAccessor(CompoundRoot.class, compoundRootAccessor);
@@ -110,6 +111,7 @@ public class OgnlValueStackFactory implements ValueStackFactory {
* {@link #setMethodAccessor} and can be configured using the extension point
* {@link StrutsConstants#STRUTS_METHOD_ACCESSOR}.
*/
@SuppressWarnings({"rawtypes", "unchecked"})
protected void registerAdditionalMethodAccessors() {
Set<String> names = container.getInstanceNames(MethodAccessor.class);
for (String name : names) {
@@ -145,6 +147,7 @@ public class OgnlValueStackFactory implements ValueStackFactory {
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
protected void registerPropertyAccessors() throws ClassNotFoundException {
Set<String> names = container.getInstanceNames(PropertyAccessor.class);
for (String name : names) {
@@ -19,7 +19,6 @@
package org.apache.struts2.ognl;
import ognl.MemberAccess;
import ognl.OgnlContext;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -54,7 +53,7 @@ import static org.apache.struts2.util.DebugUtils.logWarningForFirstOccurrence;
* Allows access decisions to be made on the basis of whether a member is static or not.
* Also blocks or allows access to properties.
*/
public class SecurityMemberAccess implements MemberAccess {
public class SecurityMemberAccess implements MemberAccess<StrutsContext> {
private static final Logger LOG = LogManager.getLogger(SecurityMemberAccess.class);
@@ -115,7 +114,7 @@ public class SecurityMemberAccess implements MemberAccess {
}
@Override
public Object setup(OgnlContext context, Object target, Member member, String propertyName) {
public Object setup(StrutsContext context, Object target, Member member, String propertyName) {
Object result = null;
if (isAccessible(context, target, member, propertyName)) {
@@ -130,7 +129,7 @@ public class SecurityMemberAccess implements MemberAccess {
}
@Override
public void restore(OgnlContext context, Object target, Member member, String propertyName, Object state) {
public void restore(StrutsContext context, Object target, Member member, String propertyName, Object state) {
if (state == null) {
return;
}
@@ -145,7 +144,7 @@ public class SecurityMemberAccess implements MemberAccess {
}
@Override
public boolean isAccessible(OgnlContext context, Object target, Member member, String propertyName) {
public boolean isAccessible(StrutsContext context, Object target, Member member, String propertyName) {
LOG.debug("Checking access for [target: {}, member: {}, property: {}]", target, member, propertyName);
if (member == null) {
@@ -0,0 +1,53 @@
/*
* 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.ognl;
import ognl.ClassResolver;
import ognl.MemberAccess;
import ognl.OgnlContext;
import ognl.TypeConverter;
/**
* Struts-specific OGNL evaluation context. Extends {@link OgnlContext} with the
* self-bounded generic parameter to enable type-safe access in all OGNL interface
* implementations ({@link MemberAccess}, {@link ognl.PropertyAccessor}, etc.).
*
* <p>Phase 1: minimal subclass delegating to super constructors.
* Future phases will promote stringly-typed map entries (e.g. {@code DENY_METHOD_EXECUTION},
* {@code CREATE_NULL_OBJECTS}) to proper typed fields.</p>
*
* @since 7.2.0
*/
public class StrutsContext extends OgnlContext<StrutsContext> {
public StrutsContext(MemberAccess<StrutsContext> memberAccess) {
super(memberAccess);
}
public StrutsContext(MemberAccess<StrutsContext> memberAccess,
ClassResolver<StrutsContext> classResolver) {
super(memberAccess, classResolver);
}
public StrutsContext(MemberAccess<StrutsContext> memberAccess,
ClassResolver<StrutsContext> classResolver,
TypeConverter<StrutsContext> typeConverter) {
super(memberAccess, classResolver, typeConverter);
}
}
@@ -18,7 +18,6 @@
*/
package org.apache.struts2.ognl;
import ognl.OgnlContext;
import org.apache.struts2.conversion.TypeConverter;
import java.lang.reflect.Member;
@@ -29,19 +28,19 @@ import java.util.Map;
*/
public class XWorkTypeConverterWrapper implements TypeConverter {
private final ognl.TypeConverter typeConverter;
private final ognl.TypeConverter<StrutsContext> typeConverter;
public XWorkTypeConverterWrapper(ognl.TypeConverter conv) {
public XWorkTypeConverterWrapper(ognl.TypeConverter<StrutsContext> conv) {
this.typeConverter = conv;
}
@Override
public Object convertValue(Map context, Object target, Member member, String propertyName, Object value, Class toType) {
// Cast context to OgnlContext for OGNL 3.4.8+ compatibility
OgnlContext ognlContext = (context instanceof OgnlContext oc) ? oc : null;
if (ognlContext == null) {
throw new IllegalArgumentException("Context must be an OgnlContext for OGNL 3.4.8+");
// Cast context to StrutsContext for OGNL 3.5.x compatibility
StrutsContext strutsContext = (context instanceof StrutsContext sc) ? sc : null;
if (strutsContext == null) {
throw new IllegalArgumentException("Context must be a StrutsContext for OGNL 3.5.x+");
}
return typeConverter.convertValue(ognlContext, target, member, propertyName, value, toType);
return typeConverter.convertValue(strutsContext, target, member, propertyName, value, toType);
}
}
@@ -26,7 +26,6 @@ import org.apache.struts2.util.ValueStack;
import ognl.MethodFailedException;
import ognl.NoSuchPropertyException;
import ognl.Ognl;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.OgnlRuntime;
import org.apache.commons.lang3.BooleanUtils;
@@ -34,6 +33,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
import org.apache.struts2.ognl.StrutsContext;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
@@ -64,7 +64,7 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
* Used by OGNl to generate bytecode
*/
@Override
public String getSourceAccessor(OgnlContext context, Object target, Object index) {
public String getSourceAccessor(StrutsContext context, Object target, Object index) {
return null;
}
@@ -72,7 +72,7 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
* Used by OGNl to generate bytecode
*/
@Override
public String getSourceSetter(OgnlContext context, Object target, Object index) {
public String getSourceSetter(StrutsContext context, Object target, Object index) {
return null;
}
@@ -96,7 +96,7 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
}
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
CompoundRoot root = (CompoundRoot) target;
for (Object o : root) {
@@ -138,7 +138,7 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
}
@Override
public Object getProperty(OgnlContext context, Object target, Object name) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object name) throws OgnlException {
CompoundRoot root = (CompoundRoot) target;
if (name instanceof Integer index) {
@@ -182,7 +182,7 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
}
@Override
public Object callMethod(OgnlContext context, Object target, String name, Object[] objects) throws MethodFailedException {
public Object callMethod(StrutsContext context, Object target, String name, Object[] objects) throws MethodFailedException {
CompoundRoot root = (CompoundRoot) target;
if ("describe".equals(name)) {
@@ -270,12 +270,12 @@ public class CompoundRootAccessor implements RootAccessor, InternalDestroyable {
}
@Override
public Object callStaticMethod(OgnlContext transientVars, Class aClass, String s, Object[] objects) throws MethodFailedException {
public Object callStaticMethod(StrutsContext transientVars, Class aClass, String s, Object[] objects) throws MethodFailedException {
return null;
}
@Override
public Class classForName(String className, OgnlContext context) throws ClassNotFoundException {
public Class classForName(String className, StrutsContext context) throws ClassNotFoundException {
Object root = Ognl.getRoot(context);
if (disallowCustomOgnlMap) {
@@ -19,20 +19,20 @@
package org.apache.struts2.ognl.accessor;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.dispatcher.HttpParameters;
import org.apache.struts2.ognl.StrutsContext;
public class HttpParametersPropertyAccessor extends ObjectPropertyAccessor {
public class HttpParametersPropertyAccessor extends ObjectPropertyAccessor<StrutsContext> {
@Override
public Object getProperty(OgnlContext context, Object target, Object oname) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object oname) throws OgnlException {
HttpParameters parameters = (HttpParameters) target;
return parameters.get(String.valueOf(oname)).getObject();
}
@Override
public void setProperty(OgnlContext context, Object target, Object oname, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object oname, Object value) throws OgnlException {
throw new OgnlException("Access to " + target.getClass().getName() + " is read-only!");
}
}
@@ -21,12 +21,12 @@ package org.apache.struts2.ognl.accessor;
import org.apache.struts2.conversion.impl.XWorkConverter;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.ognl.StrutsContext;
public class ObjectAccessor extends ObjectPropertyAccessor {
public class ObjectAccessor extends ObjectPropertyAccessor<StrutsContext> {
@Override
public Object getProperty(OgnlContext map, Object o, Object o1) throws OgnlException {
public Object getProperty(StrutsContext map, Object o, Object o1) throws OgnlException {
Object obj = super.getProperty(map, o, o1);
map.put(XWorkConverter.LAST_BEAN_CLASS_ACCESSED, o.getClass());
@@ -20,10 +20,10 @@ package org.apache.struts2.ognl.accessor;
import org.apache.struts2.ognl.ObjectProxy;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.OgnlRuntime;
import ognl.PropertyAccessor;
import org.apache.struts2.ognl.StrutsContext;
/**
* Is able to access (set/get) properties on a given object.
@@ -33,13 +33,13 @@ import ognl.PropertyAccessor;
*
* @author Gabe
*/
public class ObjectProxyPropertyAccessor implements PropertyAccessor {
public class ObjectProxyPropertyAccessor implements PropertyAccessor<StrutsContext> {
/**
* Used by OGNl to generate bytecode
*/
@Override
public String getSourceAccessor(OgnlContext context, Object target, Object index) {
public String getSourceAccessor(StrutsContext context, Object target, Object index) {
return null; //To change body of implemented methods use File | Settings | File Templates.
}
@@ -47,25 +47,27 @@ public class ObjectProxyPropertyAccessor implements PropertyAccessor {
* Used by OGNl to generate bytecode
*/
@Override
public String getSourceSetter(OgnlContext context, Object target, Object index) {
public String getSourceSetter(StrutsContext context, Object target, Object index) {
return null;
}
@Override
public Object getProperty(OgnlContext context, Object target, Object name) throws OgnlException {
@SuppressWarnings({"unchecked", "rawtypes"})
public Object getProperty(StrutsContext context, Object target, Object name) throws OgnlException {
ObjectProxy proxy = (ObjectProxy) target;
setupContext(context, proxy);
return OgnlRuntime.getPropertyAccessor(proxy.getValue().getClass()).getProperty(context, target, name);
return ((PropertyAccessor) OgnlRuntime.getPropertyAccessor(proxy.getValue().getClass())).getProperty(context, target, name);
}
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
@SuppressWarnings({"unchecked", "rawtypes"})
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
ObjectProxy proxy = (ObjectProxy) target;
setupContext(context, proxy);
OgnlRuntime.getPropertyAccessor(proxy.getValue().getClass()).setProperty(context, target, name, value);
((PropertyAccessor) OgnlRuntime.getPropertyAccessor(proxy.getValue().getClass())).setProperty(context, target, name, value);
}
/**
@@ -75,7 +77,7 @@ public class ObjectProxyPropertyAccessor implements PropertyAccessor {
* @param context
* @param proxy
*/
private void setupContext(OgnlContext context, ObjectProxy proxy) {
private void setupContext(StrutsContext context, ObjectProxy proxy) {
ReflectionContextState.setLastBeanClassAccessed(context, proxy.getLastClassAccessed());
ReflectionContextState.setLastBeanPropertyAccessed(context, proxy.getLastPropertyAccessed());
}
@@ -19,14 +19,14 @@
package org.apache.struts2.ognl.accessor;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.dispatcher.Parameter;
import org.apache.struts2.ognl.StrutsContext;
public class ParameterPropertyAccessor extends ObjectPropertyAccessor {
public class ParameterPropertyAccessor extends ObjectPropertyAccessor<StrutsContext> {
@Override
public Object getProperty(OgnlContext context, Object target, Object oname) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object oname) throws OgnlException {
if (target instanceof Parameter parameter) {
if ("value".equalsIgnoreCase(String.valueOf(oname))) {
throw new OgnlException("Access to " + oname + " is not allowed! Call parameter name directly!");
@@ -37,7 +37,7 @@ public class ParameterPropertyAccessor extends ObjectPropertyAccessor {
}
@Override
public void setProperty(OgnlContext context, Object target, Object oname, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object oname, Object value) throws OgnlException {
if (target instanceof Parameter) {
throw new OgnlException("Access to " + target.getClass().getName() + " is read-only!");
} else {
@@ -21,9 +21,10 @@ package org.apache.struts2.ognl.accessor;
import ognl.ClassResolver;
import ognl.MethodAccessor;
import ognl.PropertyAccessor;
import org.apache.struts2.ognl.StrutsContext;
/**
* @since 6.4.0
*/
public interface RootAccessor extends PropertyAccessor, MethodAccessor, ClassResolver {
public interface RootAccessor extends PropertyAccessor<StrutsContext>, MethodAccessor<StrutsContext>, ClassResolver<StrutsContext> {
}
@@ -25,10 +25,10 @@ import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.OgnlRuntime;
import ognl.SetPropertyAccessor;
import org.apache.struts2.ognl.StrutsContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -40,7 +40,7 @@ import java.util.Map;
/**
* @author Gabe
*/
public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor<StrutsContext> {
private static final Logger LOG = LogManager.getLogger(XWorkCollectionPropertyAccessor.class);
@@ -87,7 +87,7 @@ public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
* @see ognl.PropertyAccessor#getProperty(java.util.Map, Object, Object)
*/
@Override
public Object getProperty(OgnlContext context, Object target, Object key) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object key) throws OgnlException {
LOG.trace("Entering getProperty()");
//check if it is a generic type property.
@@ -186,7 +186,7 @@ public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
* Gets an indexed Map by a given key property with the key being
* the value of the property and the value being the
*/
private Map getSetMap(OgnlContext context, Collection collection, String property) throws OgnlException {
private Map getSetMap(StrutsContext context, Collection collection, String property) throws OgnlException {
LOG.trace("getting set Map");
String path = ReflectionContextState.getCurrentPropertyPath(context);
@@ -211,7 +211,7 @@ public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
/*
* gets a bean with the given
*/
public Object getPropertyThroughIteration(OgnlContext context, Collection collection, String property, Object key)
public Object getPropertyThroughIteration(StrutsContext context, Collection collection, String property, Object key)
throws OgnlException {
//TODO
for (Object currTest : collection) {
@@ -224,7 +224,7 @@ public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
}
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
Class lastClass = (Class) context.get(XWorkConverter.LAST_BEAN_CLASS_ACCESSED);
String lastProperty = (String) context.get(XWorkConverter.LAST_BEAN_PROPERTY_ACCESSED);
Class convertToClass = objectTypeDeterminer.getElementClass(lastClass, lastProperty, name);
@@ -256,7 +256,7 @@ public class XWorkCollectionPropertyAccessor extends SetPropertyAccessor {
super.setProperty(context, target, name, realValue);
}
private Object getRealValue(OgnlContext context, Object value, Class convertToClass) {
private Object getRealValue(StrutsContext context, Object value, Class convertToClass) {
if (value == null || convertToClass == null) {
return value;
}
@@ -20,15 +20,15 @@ package org.apache.struts2.ognl.accessor;
import ognl.EnumerationPropertyAccessor;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.ognl.StrutsContext;
public class XWorkEnumerationAccessor extends EnumerationPropertyAccessor {
public class XWorkEnumerationAccessor extends EnumerationPropertyAccessor<StrutsContext> {
private final ObjectPropertyAccessor opa = new ObjectPropertyAccessor();
private final ObjectPropertyAccessor<StrutsContext> opa = new ObjectPropertyAccessor<>();
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
opa.setProperty(context, target, name, value);
}
}
@@ -20,15 +20,15 @@ package org.apache.struts2.ognl.accessor;
import ognl.IteratorPropertyAccessor;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.ognl.StrutsContext;
public class XWorkIteratorPropertyAccessor extends IteratorPropertyAccessor {
public class XWorkIteratorPropertyAccessor extends IteratorPropertyAccessor<StrutsContext> {
private final ObjectPropertyAccessor opa = new ObjectPropertyAccessor();
private final ObjectPropertyAccessor<StrutsContext> opa = new ObjectPropertyAccessor<>();
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
opa.setProperty(context, target, name, value);
}
}
@@ -25,9 +25,9 @@ import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.ListPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.PropertyAccessor;
import org.apache.struts2.ognl.StrutsContext;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
@@ -41,7 +41,7 @@ import java.util.List;
*
* @author Gabriel Zimmerman
*/
public class XWorkListPropertyAccessor extends ListPropertyAccessor {
public class XWorkListPropertyAccessor extends ListPropertyAccessor<StrutsContext> {
private XWorkCollectionPropertyAccessor _sAcc = new XWorkCollectionPropertyAccessor();
@@ -57,7 +57,7 @@ public class XWorkListPropertyAccessor extends ListPropertyAccessor {
}
@Inject("java.util.Collection")
public void setXWorkCollectionPropertyAccessor(PropertyAccessor acc) {
public void setXWorkCollectionPropertyAccessor(PropertyAccessor<StrutsContext> acc) {
this._sAcc = (XWorkCollectionPropertyAccessor) acc;
}
@@ -82,7 +82,7 @@ public class XWorkListPropertyAccessor extends ListPropertyAccessor {
}
@Override
public Object getProperty(OgnlContext context, Object target, Object name) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object name) throws OgnlException {
if (ReflectionContextState.isGettingByKeyProperty(context)
|| name.equals(XWorkCollectionPropertyAccessor.KEY_PROPERTY_FOR_CREATION)) {
@@ -137,7 +137,7 @@ public class XWorkListPropertyAccessor extends ListPropertyAccessor {
}
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value)
public void setProperty(StrutsContext context, Object target, Object name, Object value)
throws OgnlException {
Class lastClass = (Class) context.get(XWorkConverter.LAST_BEAN_CLASS_ACCESSED);
@@ -185,7 +185,7 @@ public class XWorkListPropertyAccessor extends ListPropertyAccessor {
super.setProperty(context, target, name, realValue);
}
private Object getRealValue(OgnlContext context, Object value, Class convertToClass) {
private Object getRealValue(StrutsContext context, Object value, Class convertToClass) {
if (value == null || convertToClass == null) {
return value;
}
@@ -24,8 +24,8 @@ import org.apache.struts2.conversion.impl.XWorkConverter;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.MapPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.ognl.StrutsContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -37,7 +37,7 @@ import java.util.Map;
*
* @author Gabriel Zimmerman
*/
public class XWorkMapPropertyAccessor extends MapPropertyAccessor {
public class XWorkMapPropertyAccessor extends MapPropertyAccessor<StrutsContext> {
private static final Logger LOG = LogManager.getLogger(XWorkMapPropertyAccessor.class);
@@ -63,7 +63,7 @@ public class XWorkMapPropertyAccessor extends MapPropertyAccessor {
}
@Override
public Object getProperty(OgnlContext context, Object target, Object name) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object name) throws OgnlException {
LOG.trace("Entering getProperty ({},{},{})", context, target, name);
ReflectionContextState.updateCurrentPropertyPath(context, name);
@@ -123,7 +123,7 @@ public class XWorkMapPropertyAccessor extends MapPropertyAccessor {
}
@Override
public void setProperty(OgnlContext context, Object target, Object name, Object value) throws OgnlException {
public void setProperty(StrutsContext context, Object target, Object name, Object value) throws OgnlException {
LOG.trace("Entering setProperty({},{},{},{})", context, target, name, value);
Object key = getKey(context, name);
@@ -131,7 +131,7 @@ public class XWorkMapPropertyAccessor extends MapPropertyAccessor {
map.put(key, getValue(context, value));
}
private Object getValue(OgnlContext context, Object value) {
private Object getValue(StrutsContext context, Object value) {
Class lastClass = (Class) context.get(XWorkConverter.LAST_BEAN_CLASS_ACCESSED);
String lastProperty = (String) context.get(XWorkConverter.LAST_BEAN_PROPERTY_ACCESSED);
if (lastClass == null || lastProperty == null) {
@@ -144,7 +144,7 @@ public class XWorkMapPropertyAccessor extends MapPropertyAccessor {
return xworkConverter.convertValue(context, value, elementClass);
}
private Object getKey(OgnlContext context, Object name) {
private Object getKey(StrutsContext context, Object name) {
Class lastClass = (Class) context.get(XWorkConverter.LAST_BEAN_CLASS_ACCESSED);
String lastProperty = (String) context.get(XWorkConverter.LAST_BEAN_PROPERTY_ACCESSED);
if (lastClass == null || lastProperty == null) {
@@ -21,9 +21,9 @@ package org.apache.struts2.ognl.accessor;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.MethodFailedException;
import ognl.ObjectMethodAccessor;
import ognl.OgnlContext;
import ognl.OgnlRuntime;
import ognl.PropertyAccessor;
import org.apache.struts2.ognl.StrutsContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -38,12 +38,13 @@ import java.util.Collection;
* @author Patrick Lightbody
* @author tmjee
*/
public class XWorkMethodAccessor extends ObjectMethodAccessor {
public class XWorkMethodAccessor extends ObjectMethodAccessor<StrutsContext> {
private static final Logger LOG = LogManager.getLogger(XWorkMethodAccessor.class);
@Override
public Object callMethod(OgnlContext context, Object object, String string, Object[] objects) throws MethodFailedException {
@SuppressWarnings("unchecked")
public Object callMethod(StrutsContext context, Object object, String string, Object[] objects) throws MethodFailedException {
//Collection property accessing
//this if statement ensures that ognl
@@ -94,7 +95,7 @@ public class XWorkMethodAccessor extends ObjectMethodAccessor {
}
}
private Object callMethodWithDebugInfo(OgnlContext context, Object object, String methodName, Object[] objects) throws MethodFailedException {
private Object callMethodWithDebugInfo(StrutsContext context, Object object, String methodName, Object[] objects) throws MethodFailedException {
try {
return super.callMethod(context, object, methodName, objects);
} catch (MethodFailedException e) {
@@ -109,7 +110,7 @@ public class XWorkMethodAccessor extends ObjectMethodAccessor {
}
@Override
public Object callStaticMethod(OgnlContext context, Class aClass, String string, Object[] objects) throws MethodFailedException {
public Object callStaticMethod(StrutsContext context, Class aClass, String string, Object[] objects) throws MethodFailedException {
boolean e = ReflectionContextState.isDenyMethodExecution(context);
if (!e) {
@@ -119,7 +120,7 @@ public class XWorkMethodAccessor extends ObjectMethodAccessor {
}
}
private Object callStaticMethodWithDebugInfo(OgnlContext context, Class aClass, String methodName,
private Object callStaticMethodWithDebugInfo(StrutsContext context, Class aClass, String methodName,
Object[] objects) throws MethodFailedException {
try {
return super.callStaticMethod(context, aClass, methodName, objects);
@@ -21,15 +21,15 @@ package org.apache.struts2.ognl.accessor;
import org.apache.struts2.conversion.impl.XWorkConverter;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.ObjectPropertyAccessor;
import ognl.OgnlContext;
import ognl.OgnlException;
import org.apache.struts2.ognl.StrutsContext;
/**
* @author Gabe
*/
public class XWorkObjectPropertyAccessor extends ObjectPropertyAccessor {
public class XWorkObjectPropertyAccessor extends ObjectPropertyAccessor<StrutsContext> {
@Override
public Object getProperty(OgnlContext context, Object target, Object oname) throws OgnlException {
public Object getProperty(StrutsContext context, Object target, Object oname) throws OgnlException {
//set the last set objects in the context
//so if the next objects accessed are
//Maps or Collections they can use the information
@@ -23,7 +23,6 @@ import org.apache.struts2.ActionInvocation;
import org.apache.struts2.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.struts2.dispatcher.mapper.ActionMapper;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
@@ -105,7 +104,7 @@ public class PostbackResult extends StrutsResultSupport {
// Render
PrintWriter pw = new PrintWriter(response.getOutputStream());
pw.write("<!DOCTYPE html><html><body><form action=\"" + StringEscapeUtils.escapeHtml4(finalLocation) + "\" method=\"POST\">");
pw.write("<!DOCTYPE html><html><body><form action=\"" + finalLocation + "\" method=\"POST\">");
writeFormElements(request, pw);
writePrologueScript(pw);
pw.write("</html>");
@@ -26,7 +26,6 @@ import org.apache.struts2.util.reflection.ReflectionException;
import org.apache.struts2.util.reflection.ReflectionExceptionHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.dispatcher.Dispatcher;
@@ -249,7 +248,7 @@ public class ServletRedirectResult extends StrutsResultSupport implements Reflec
response.setStatus(statusCode);
response.setHeader("Location", finalLocation);
try {
response.getWriter().write(StringEscapeUtils.escapeHtml4(finalLocation));
response.getWriter().write(finalLocation);
} finally {
response.getWriter().close();
}
@@ -18,8 +18,6 @@
*/
package org.apache.struts2.util;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.apache.struts2.config.ConfigurationException;
import org.apache.struts2.ognl.OgnlUtil;
@@ -35,17 +33,6 @@ import static java.util.stream.Collectors.toSet;
import static org.apache.commons.lang3.StringUtils.strip;
public class ConfigParseUtil {
// Size the cache to prevent excessive memory usage in environments with many classloaders and/or large numbers of classes being validated.
// While still providing a reasonable caching benefit for common cases (e.g. multiple Struts instances in the same container, or multiple calls to validate the same class across different containers).
// The cache is sized to allow for some level of caching across multiple classloaders, while still allowing for a reasonable number of classes to be cached per classloader.
private static final int MAX_CLASSLOADER_CACHE_SIZE = 25;
// The cache for validated classes is a two-level cache, with the first level keyed by ClassLoader and the second level keyed by class name.
private static final int MAX_CLASS_CACHE_PER_LOADER_SIZE = 50;
private static final Cache<ClassLoader, Cache<String, Class<?>>> VALIDATED_CLASS_CACHE = Caffeine.newBuilder()
.weakKeys()
.maximumSize(MAX_CLASSLOADER_CACHE_SIZE)
.build();
private ConfigParseUtil() {
}
@@ -86,7 +73,7 @@ public class ConfigParseUtil {
Set<Class<?>> classes = new HashSet<>();
for (String className : classNames) {
try {
classes.add(loadAndCacheClass(validatingClassLoader, className));
classes.add(validatingClassLoader.loadClass(className));
} catch (ClassNotFoundException e) {
throw new ConfigurationException("Cannot load class for exclusion/exemption configuration: " + className, e);
}
@@ -94,35 +81,6 @@ public class ConfigParseUtil {
return classes;
}
private static Class<?> loadAndCacheClass(ClassLoader validatingClassLoader, String className) throws ClassNotFoundException {
Cache<String, Class<?>> classLoaderCache = VALIDATED_CLASS_CACHE.get(validatingClassLoader,
key -> Caffeine.newBuilder().weakValues().maximumSize(MAX_CLASS_CACHE_PER_LOADER_SIZE).build());
try {
return classLoaderCache.get(className, key -> {
try {
return validatingClassLoader.loadClass(key);
} catch (ClassNotFoundException e) {
throw new ClassLookupException(e);
}
});
} catch (ClassLookupException e) {
// The ClassLookupException only serves to wrap the checked ClassNotFoundException thrown by ClassLoader.loadClass.
throw (ClassNotFoundException) e.getCause();
}
}
/**
* This is a wrapper class to allow the checked ClassNotFoundException thrown by ClassLoader.loadClass to be propagated
* We should always be able to unwrap this exception without risk of ClassCastException since the only code that can throw it is the mapping function passed to the cache
* and it only ever throws this wrapper with a ClassNotFoundException cause.
*/
private static final class ClassLookupException extends RuntimeException {
private ClassLookupException(ClassNotFoundException cause) {
super(cause);
}
}
public static Set<String> toPackageNamesSet(String newDelimitedPackageNames) throws ConfigurationException {
Set<String> packageNames = commaDelimitedStringToSet(newDelimitedPackageNames)
.stream().map(s -> strip(s, ".")).collect(toSet());
@@ -18,7 +18,6 @@
*/
package org.apache.struts2.util;
import org.apache.struts2.dispatcher.Dispatcher;
import org.apache.struts2.ActionContext;
import org.apache.struts2.text.LocalizedTextProvider;
import org.apache.logging.log4j.LogManager;
@@ -30,7 +29,6 @@ import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.Map;
import java.util.Random;
import static org.apache.commons.lang3.StringUtils.normalizeSpace;
/**
* TokenHelper
@@ -188,15 +186,10 @@ public class TokenHelper {
if (!token.equals(sessionToken)) {
if (LOG.isWarnEnabled()) {
LocalizedTextProvider localizedTextProvider = ActionContext.getContext().getContainer().getInstance(LocalizedTextProvider.class);
LOG.warn(localizedTextProvider.findText(TokenHelper.class, "struts.internal.invalid.token", ActionContext.getContext().getLocale(), "Form token {0} does not match the expected session token.", new Object[]{
normalizeSpace(token)
LOG.warn(localizedTextProvider.findText(TokenHelper.class, "struts.internal.invalid.token", ActionContext.getContext().getLocale(), "Form token {0} does not match the session token {1}.", new Object[]{
token, sessionToken
}));
}
Dispatcher dispatcher = Dispatcher.getInstance();
if (dispatcher != null && dispatcher.isDevMode()) {
LOG.warn("Token mismatch detail - token name [{}], form token [{}], session token [{}]",
normalizeSpace(tokenName), normalizeSpace(token), sessionToken);
}
return false;
}
@@ -177,6 +177,7 @@ public class FreemarkerManager {
protected int mruMaxStrongSize;
protected String templateUpdateDelay;
protected boolean whitespaceStripping = true;
protected boolean devMode;
protected Map<String, TagLibraryModelProvider> tagLibraries;
private FileManager fileManager;
@@ -212,6 +213,11 @@ public class FreemarkerManager {
this.whitespaceStripping = BooleanUtils.toBoolean(whitespaceStripping);
}
@Inject(value = StrutsConstants.STRUTS_DEVMODE, required = false)
public void setDevMode(String devMode) {
this.devMode = BooleanUtils.toBoolean(devMode);
}
@Inject
public void setContainer(Container container) {
Map<String, TagLibraryModelProvider> map = new HashMap<>();
@@ -346,8 +352,9 @@ public class FreemarkerManager {
}
LOG.debug("Disabled localized lookups");
configuration.setLocalizedLookup(false);
LOG.debug("Whitespace stripping: {}", whitespaceStripping);
configuration.setWhitespaceStripping(whitespaceStripping);
boolean enableWhitespaceStripping = whitespaceStripping && !devMode;
LOG.debug("Whitespace stripping: {} (configured: {}, devMode: {})", enableWhitespaceStripping, whitespaceStripping, devMode);
configuration.setWhitespaceStripping(enableWhitespaceStripping);
LOG.debug("Sets NewBuiltinClassResolver to TemplateClassResolver.SAFER_RESOLVER");
configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
LOG.debug("Sets HTML as an output format and escaping policy");
@@ -257,11 +257,6 @@ struts.parameters.requireAnnotations=true
### Useful for transitioning legacy applications, but highly recommended to set to false as soon as possible!
struts.parameters.requireAnnotations.transitionMode=false
### Whether ChainingInterceptor enforces @StrutsParameter on the target action when copying properties.
### Opt-in hardening; default false preserves legacy chaining behaviour. Only has effect when
### struts.parameters.requireAnnotations is also enabled.
struts.chaining.requireAnnotations=false
### Whether to throw a RuntimeException when a property is not found
### in an expression, or when the expression evaluation fails
struts.el.throwExceptionOnFailure=false
@@ -20,7 +20,7 @@
# See https://issues.apache.org/jira/browse/WW-4195 for more details!
struts.messages.invalid.token=The form has already been processed or no token was supplied, please try again.
struts.internal.invalid.token=Form token {0} does not match the expected session token.
struts.internal.invalid.token=Form token {0} does not match the session token {1}.
struts.messages.bypass.request=Bypassing {0}/{1}
struts.messages.current.file=File {0} {1} {2} {3}
@@ -17,7 +17,7 @@
# under the License.
#
struts.messages.invalid.token=Denne form er allerede blevet behandlet eller der mangler en token, venligst pr\u00F8v igen.
struts.internal.invalid.token=Form token {0} passer ikke med den forventede session-token.
struts.internal.invalid.token=Form token {0} passer ikke med den token som findes i session {1}.
struts.messages.bypass.request=Springer over {0}/{1}
struts.messages.current.file=Fil {0} {1} {2} {3}
@@ -17,7 +17,7 @@
# under the License.
#
struts.messages.invalid.token=Das Formular wurde bereits verarbeitet oder es wurde kein Token angegeben, bitte versuchen Sie es erneut.
struts.internal.invalid.token=Das Formular-Token {0} stimmt nicht mit dem erwarteten Session-Token \u00FCberein.
struts.internal.invalid.token=Das Formular Token {0} stimmt nicht mit dem Session Token {1} \u00FCberein.
struts.messages.bypass.request=\u00DCberspringe {0}/{1}
struts.messages.current.file=Datei {0} {1} {2} {3}
@@ -21,7 +21,7 @@
# See https://issues.apache.org/jira/browse/WW-4195 for more details!
struts.messages.invalid.token=The form has already been processed or no token was supplied, please try again.
struts.internal.invalid.token=Form token {0} does not match the expected session token.
struts.internal.invalid.token=Form token {0} does not match the session token {1}.
struts.messages.bypass.request=Bypassing {0}/{1}
struts.messages.current.file=File {0} {1} {2} {3}
@@ -17,7 +17,7 @@
# under the License.
#
struts.messages.invalid.token=Formularz zosta\u0142 ju\u017C przetworzony lub nie za\u0142\u0105czono tokena, spr\u00F3buj ponownie.
struts.internal.invalid.token=Token formularza {0} nie pasuje do oczekiwanego tokena sesji.
struts.internal.invalid.token=Token formularza {0} nie pasuje do tokena sesji {1}.
struts.messages.bypass.request=Omijanie {0}/{1}
struts.messages.current.file=Plik {0} {1} {2} {3}
@@ -17,7 +17,7 @@
# under the License.
#
struts.messages.invalid.token=O formulario j\u00E1 foi processado ou nenhum token foi gerado, por favor tente novamente.
struts.internal.invalid.token=O token do formul\u00E1rio {0} \u00E9 diferente do token de sess\u00E3o esperado.
struts.internal.invalid.token=O token do formul\u00E1rio {0} \u00E9 diferente do token de sess\u00E3o {1}.
struts.messages.bypass.request=Ignorando {0}/ {1}
struts.messages.current.file=Arquivo {0} {1} {2} {3}
-6
View File
@@ -245,12 +245,6 @@
<bean type="org.apache.struts2.util.ProxyService" name="struts"
class="org.apache.struts2.util.StrutsProxyService" scope="singleton"/>
<bean type="org.apache.struts2.interceptor.parameter.ParameterAuthorizer" name="struts"
class="org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer" scope="singleton"/>
<bean type="org.apache.struts2.interceptor.parameter.ParameterAllowlister" name="struts"
class="org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister" scope="singleton"/>
<bean type="org.apache.struts2.url.QueryStringBuilder" name="strutsQueryStringBuilder"
class="org.apache.struts2.url.StrutsQueryStringBuilder" scope="singleton"/>
<bean type="org.apache.struts2.url.QueryStringParser" name="strutsQueryStringParser"
@@ -1,47 +0,0 @@
/*
* 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.multipart;
import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload;
import org.apache.struts2.StrutsException;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class AbstractMultiPartRequestApiCheckTest {
@Test
public void verifyFileUploadApiPassesForCompatibleClass() {
assertThatCode(() -> AbstractMultiPartRequest.verifyFileUploadApi(JakartaServletDiskFileUpload.class))
.doesNotThrowAnyException();
}
@Test
public void verifyFileUploadApiThrowsForIncompatibleClass() {
assertThatThrownBy(() -> AbstractMultiPartRequest.verifyFileUploadApi(IncompatibleFileUpload.class))
.isInstanceOf(StrutsException.class)
.hasMessageContaining("setMaxSize")
.hasMessageContaining("Align commons-fileupload2-core");
}
/** Stub lacking the size-limit setters, simulating a binary-incompatible fileupload version. */
private static class IncompatibleFileUpload {
}
}
@@ -1,45 +0,0 @@
/*
* 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.interceptor;
import org.apache.struts2.action.Action;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
/**
* Test fixture: target/source action whose {@code managerApproved} property is annotated with
* {@link StrutsParameter}. Used by {@link ChainingInterceptorTest}.
*/
public class AnnotatedChainingAction implements Action {
private boolean managerApproved;
public boolean getManagerApproved() {
return managerApproved;
}
@StrutsParameter
public void setManagerApproved(boolean managerApproved) {
this.managerApproved = managerApproved;
}
@Override
public String execute() {
return SUCCESS;
}
}
@@ -27,13 +27,7 @@ import org.apache.struts2.SimpleAction;
import org.apache.struts2.TestBean;
import org.apache.struts2.XWorkTestCase;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.util.ProxyService;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import java.beans.IntrospectionException;
import java.util.*;
/**
@@ -155,151 +149,6 @@ public class ChainingInterceptorTest extends XWorkTestCase {
}
private StrutsParameterAuthorizer buildAuthorizer(boolean requireAnnotations, boolean transitionMode) {
StrutsParameterAuthorizer authorizer = new StrutsParameterAuthorizer();
authorizer.setOgnlUtil(container.getInstance(OgnlUtil.class));
authorizer.setProxyService(container.getInstance(ProxyService.class));
authorizer.setRequireAnnotations(String.valueOf(requireAnnotations));
authorizer.setRequireAnnotationsTransitionMode(String.valueOf(transitionMode));
return authorizer;
}
private void enableChainingEnforcement(boolean requireAnnotations, boolean transitionMode) {
interceptor.setParameterAuthorizer(buildAuthorizer(requireAnnotations, transitionMode));
interceptor.setRequireAnnotations("true");
}
public void testFlagOffCopiesUnannotatedProperty() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
interceptor.intercept(invocation);
assertTrue("legacy chaining should copy the property when flag is off", target.getManagerApproved());
}
public void testFlagOnSkipsUnannotatedProperty() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
enableChainingEnforcement(true, false);
interceptor.intercept(invocation);
assertFalse("unannotated target property must NOT be copied when enforcement is on",
target.getManagerApproved());
}
public void testFlagOnCopiesAnnotatedProperty() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
AnnotatedChainingAction target = new AnnotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
enableChainingEnforcement(true, false);
interceptor.intercept(invocation);
assertTrue("annotated target property should be copied when enforcement is on",
target.getManagerApproved());
}
public void testTransitionModeCopiesNonNestedUnannotatedProperty() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
enableChainingEnforcement(true, true);
interceptor.intercept(invocation);
assertTrue("transition mode should copy depth-0 property without annotation",
target.getManagerApproved());
}
public void testRequireAnnotationsFalseIsNoOp() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
interceptor.setParameterAuthorizer(buildAuthorizer(false, false));
interceptor.setRequireAnnotations("true");
interceptor.intercept(invocation);
assertTrue("when global requireAnnotations is off, enforcement is a no-op",
target.getManagerApproved());
}
public void testEnforcementStillFiltersWithIncludesConfigured() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
interceptor.setIncludes("managerApproved");
enableChainingEnforcement(true, false);
interceptor.intercept(invocation);
assertFalse("unauthorized property must be excluded even when listed in includes",
target.getManagerApproved());
}
public void testEnforcementResolvesProxiedTargetClass() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
UnannotatedChainingAction target = new UnannotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
ProxyService proxyService = Mockito.mock(ProxyService.class);
Mockito.when(proxyService.isProxy(ArgumentMatchers.any())).thenReturn(true);
Mockito.when(proxyService.ultimateTargetClass(ArgumentMatchers.any()))
.thenReturn((Class) UnannotatedChainingAction.class);
interceptor.setProxyService(proxyService);
enableChainingEnforcement(true, false);
interceptor.intercept(invocation);
assertFalse("proxied unannotated target property must NOT be copied", target.getManagerApproved());
}
public void testFailsClosedWhenTargetCannotBeIntrospected() throws Exception {
AnnotatedChainingAction source = new AnnotatedChainingAction();
source.setManagerApproved(true);
AnnotatedChainingAction target = new AnnotatedChainingAction();
mockInvocation.matchAndReturn("getAction", target);
stack.push(source);
stack.push(target);
// Introspection failure must fail closed: copy nothing, even for an annotated property.
OgnlUtil ognlUtil = Mockito.mock(OgnlUtil.class);
Mockito.when(ognlUtil.getBeanInfo(ArgumentMatchers.any(Class.class)))
.thenThrow(new IntrospectionException("boom"));
interceptor.setOgnlUtil(ognlUtil);
enableChainingEnforcement(true, false);
interceptor.intercept(invocation);
assertFalse("nothing should be copied when the target cannot be introspected",
target.getManagerApproved());
}
@Override
protected void setUp() throws Exception {
super.setUp();
@@ -1,219 +0,0 @@
/*
* 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.interceptor;
import jakarta.servlet.http.Cookie;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.StrutsInternalTestCase;
import org.apache.struts2.action.Action;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.mock.MockActionInvocation;
import org.apache.struts2.util.ValueStack;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
public class CookieInterceptorAnnotationTest extends StrutsInternalTestCase {
private CookieInterceptor interceptor;
@Override
protected void setUp() throws Exception {
super.setUp();
interceptor = container.inject(CookieInterceptor.class);
interceptor.setCookiesName("*");
}
@Override
protected void tearDown() throws Exception {
// Reset shared singleton state flags flipped on the container's StrutsParameterAuthorizer
// would otherwise leak across tests in the same JVM run.
configureRequireAnnotations(false, false);
super.tearDown();
}
public void testRequireAnnotations_unannotatedSetter_isSkipped() throws Exception {
configureRequireAnnotations(true, false);
AnnotatedAction action = new AnnotatedAction();
invokeWithCookies(action, new Cookie("unannotated", "v"));
assertNull("unannotated setter must not be populated", action.getUnannotated());
assertNull(ActionContext.getContext().getValueStack().findValue("unannotated"));
}
public void testRequireAnnotations_annotatedSetter_isInjected() throws Exception {
configureRequireAnnotations(true, false);
AnnotatedAction action = new AnnotatedAction();
invokeWithCookies(action, new Cookie("annotated", "v"));
assertEquals("v", action.getAnnotated());
assertEquals("v", ActionContext.getContext().getValueStack().findValue("annotated"));
}
public void testRequireAnnotations_annotatedNestedPath_isInjected() throws Exception {
configureRequireAnnotations(true, false);
AnnotatedAction action = new AnnotatedAction();
action.setNested(new NestedBean());
invokeWithCookies(action, new Cookie("nested.field", "v"));
assertEquals("v", action.getNested().getField());
}
public void testRequireAnnotations_unannotatedNestedPath_isSkipped() throws Exception {
configureRequireAnnotations(true, false);
AnnotatedAction action = new AnnotatedAction();
action.setUnannotatedNested(new NestedBean());
invokeWithCookies(action, new Cookie("unannotatedNested.field", "v"));
assertNull(action.getUnannotatedNested().getField());
}
public void testRequireAnnotations_transitionMode_exemptsDepthZero() throws Exception {
configureRequireAnnotations(true, true);
AnnotatedAction action = new AnnotatedAction();
invokeWithCookies(action, new Cookie("unannotated", "v"));
assertEquals("v", action.getUnannotated());
}
public void testDefaultConfig_unannotatedSetter_stillInjected() throws Exception {
configureRequireAnnotations(false, false);
AnnotatedAction action = new AnnotatedAction();
invokeWithCookies(action, new Cookie("unannotated", "v"));
assertEquals("v", action.getUnannotated());
}
public void testRequireAnnotations_modelDriven_exemptsModel() throws Exception {
configureRequireAnnotations(true, false);
ModelDrivenAction action = new ModelDrivenAction();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie("name", "v"));
ServletActionContext.setRequest(request);
// ModelDriven contract: the model is pushed on top of the action.
ActionContext.getContext().getValueStack().push(action);
ActionContext.getContext().getValueStack().push(action.getModel());
MockActionInvocation invocation = new MockActionInvocation();
invocation.setAction(action);
invocation.setInvocationContext(ActionContext.getContext());
invocation.setResultCode(Action.SUCCESS);
interceptor.intercept(invocation);
assertEquals("v", action.getModel().getName());
}
public void testSubclassOverridingDeprecatedHook_stillSeesAuthorizationGate() throws Exception {
configureRequireAnnotations(true, false);
AtomicInteger calls = new AtomicInteger();
@SuppressWarnings("deprecation")
CookieInterceptor subclass = new CookieInterceptor() {
@Override
protected void populateCookieValueIntoStack(String name, String value, Map<String, String> map, ValueStack stack) {
calls.incrementAndGet();
super.populateCookieValueIntoStack(name, value, map, stack);
}
};
container.inject(subclass);
subclass.setCookiesName("*");
AnnotatedAction action = new AnnotatedAction();
MockHttpServletRequest req = new MockHttpServletRequest();
req.setCookies(new Cookie("annotated", "ok"), new Cookie("unannotated", "blocked"));
ServletActionContext.setRequest(req);
ActionContext.getContext().getValueStack().push(action);
MockActionInvocation invocation = new MockActionInvocation();
invocation.setAction(action);
invocation.setInvocationContext(ActionContext.getContext());
invocation.setResultCode(Action.SUCCESS);
subclass.intercept(invocation);
assertEquals("ok", action.getAnnotated());
assertNull(action.getUnannotated());
assertEquals("4-arg hook should be invoked exactly once (only for the authorized cookie)", 1, calls.get());
}
private void configureRequireAnnotations(boolean require, boolean transitionMode) {
StrutsParameterAuthorizer authorizer = (StrutsParameterAuthorizer) container.getInstance(ParameterAuthorizer.class);
authorizer.setRequireAnnotations(Boolean.toString(require));
authorizer.setRequireAnnotationsTransitionMode(Boolean.toString(transitionMode));
}
private void invokeWithCookies(Object action, Cookie... cookies) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(cookies);
ServletActionContext.setRequest(request);
ActionContext.getContext().getValueStack().push(action);
MockActionInvocation invocation = new MockActionInvocation();
invocation.setAction(action);
invocation.setInvocationContext(ActionContext.getContext());
invocation.setResultCode(Action.SUCCESS);
interceptor.intercept(invocation);
}
public static class AnnotatedAction extends ActionSupport {
private String annotated;
private String unannotated;
private NestedBean nested;
private NestedBean unannotatedNested;
@StrutsParameter
public void setAnnotated(String v) { this.annotated = v; }
public String getAnnotated() { return annotated; }
public void setUnannotated(String v) { this.unannotated = v; }
public String getUnannotated() { return unannotated; }
@StrutsParameter(depth = 1)
public NestedBean getNested() { return nested; }
public void setNested(NestedBean nested) { this.nested = nested; }
public NestedBean getUnannotatedNested() { return unannotatedNested; }
public void setUnannotatedNested(NestedBean v) { this.unannotatedNested = v; }
}
public static class NestedBean {
private String field;
public String getField() { return field; }
public void setField(String f) { this.field = f; }
}
public static class ModelDrivenAction extends ActionSupport implements ModelDriven<Model> {
private final Model model = new Model();
@Override
public Model getModel() { return model; }
}
public static class Model {
private String name;
public String getName() { return name; }
public void setName(String n) { this.name = n; }
}
}
@@ -43,15 +43,6 @@ import static org.easymock.EasyMock.verify;
public class CookieInterceptorTest extends StrutsInternalTestCase {
/**
* These tests construct {@link CookieInterceptor} via {@code new} rather than the DI container, so the
* {@code @StrutsParameter} authorization gate added in WW-5627 has no injected services. We supply explicit
* pass-through lambdas to mirror the default-config behavior these tests assume ({@code requireAnnotations=false}).
*/
private static void disableAuthorizationGate(CookieInterceptor interceptor) {
interceptor.setParameterAuthorizer((name, target, action) -> true);
interceptor.setParameterAllowlister((name, target) -> {});
}
public void testIntercepDefault() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
@@ -77,7 +68,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.intercept(invocation);
@@ -115,7 +105,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("*");
interceptor.setCookiesValue("*");
interceptor.intercept(invocation);
@@ -158,7 +147,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("cookie1, cookie2, cookie3");
interceptor.setCookiesValue("cookie1value, cookie2value, cookie3value");
interceptor.intercept(invocation);
@@ -200,7 +188,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("cookie1, cookie3");
interceptor.setCookiesValue("cookie1value, cookie2value, cookie3value");
interceptor.intercept(invocation);
@@ -243,7 +230,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("cookie1, cookie3");
interceptor.setCookiesValue("*");
interceptor.intercept(invocation);
@@ -285,7 +271,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("cookie1, cookie3");
interceptor.setCookiesValue("");
interceptor.intercept(invocation);
@@ -328,7 +313,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("cookie1, cookie3");
interceptor.setCookiesValue("cookie1value");
interceptor.intercept(invocation);
@@ -411,7 +395,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
excludedPatternsChecker.setAdditionalExcludePatterns(".*(^|\\.|\\[|'|\")class(\\.|\\[|'|\").*");
interceptor.setExcludedPatternsChecker(excludedPatternsChecker);
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("*");
MockActionInvocation invocation = new MockActionInvocation();
@@ -458,7 +441,6 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
};
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("*");
MockActionInvocation invocation = new MockActionInvocation();
@@ -1,43 +0,0 @@
/*
* 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.interceptor;
import org.apache.struts2.action.Action;
/**
* Test fixture: target action whose {@code managerApproved} property is NOT annotated with
* {@code @StrutsParameter}. Used by {@link ChainingInterceptorTest}.
*/
public class UnannotatedChainingAction implements Action {
private boolean managerApproved;
public boolean getManagerApproved() {
return managerApproved;
}
public void setManagerApproved(boolean managerApproved) {
this.managerApproved = managerApproved;
}
@Override
public String execute() {
return SUCCESS;
}
}
@@ -19,8 +19,6 @@
package org.apache.struts2.interceptor.httpmethod;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionProxy;
import org.apache.struts2.config.StrutsXmlConfigurationProvider;
import org.apache.struts2.mock.MockActionInvocation;
import org.apache.struts2.mock.MockActionProxy;
import org.apache.struts2.HttpMethodsTestAction;
@@ -28,8 +26,6 @@ import org.apache.struts2.StrutsInternalTestCase;
import org.apache.struts2.TestAction;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Map;
public class HttpMethodInterceptorTest extends StrutsInternalTestCase {
private HttpMethodInterceptor interceptor;
@@ -277,83 +273,6 @@ public class HttpMethodInterceptorTest extends StrutsInternalTestCase {
invocation.setProxy(actionProxy);
}
/**
* Regression: wildcard-resolved method with NO method-level annotation on a class
* that has a class-level @AllowedHttpMethod(POST) GET must be rejected.
* The WW-5535 fix introduced an if/else-if that made the class-level check
* unreachable when isMethodSpecified()=true and the method is unannotated.
*/
public void testWildcardResolvedUnannotatedMethodRespectsClassLevelAnnotation() throws Exception {
// given HttpMethodsTestAction has @AllowedHttpMethod(POST) at class level
// execute() inherited from ActionSupport has no method-level HTTP annotation
HttpMethodsTestAction action = new HttpMethodsTestAction();
prepareActionInvocation(action);
actionProxy.setMethod("execute");
actionProxy.setMethodSpecified(true); // simulates wildcard-resolved, not default
prepareRequest("get");
// when
String resultName = interceptor.intercept(invocation);
// then class-level @AllowedHttpMethod(POST) must still be enforced
assertEquals("bad-request", resultName);
}
/**
* Counterpart: POST on wildcard-resolved unannotated method must succeed
* when the class allows POST via class-level annotation.
*/
public void testWildcardResolvedUnannotatedMethodAllowsPostWithClassLevelAnnotation() throws Exception {
// given
HttpMethodsTestAction action = new HttpMethodsTestAction();
prepareActionInvocation(action);
actionProxy.setMethod("execute");
actionProxy.setMethodSpecified(true);
invocation.setResultCode("success");
prepareRequest("post");
// when
String resultName = interceptor.intercept(invocation);
// then
assertEquals("success", resultName);
}
/**
* Integration regression for WW-5535: exercise the full wildcard resolution path through
* a real {@link org.apache.struts2.DefaultActionProxy} (not a MockActionProxy).
* <p>
* Config: {@code <action name="Wild-*" class="HttpMethodsTestAction" method="{1}">} —
* URL {@code Wild-execute} resolves to method {@code execute()} inherited from
* {@code ActionSupport} (no method-level HTTP annotation). The class carries
* {@code @AllowedHttpMethod(POST)}, so GET must be rejected end-to-end.
*/
public void testWildcardResolvedExecuteRejectsGetThroughRealProxy() throws Exception {
loadConfigurationProviders(new StrutsXmlConfigurationProvider(
"org/apache/struts2/config/providers/xwork-test-allowed-methods.xml"));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/Wild-execute");
Map<String, Object> extraContext = ActionContext.of()
.withServletRequest(request)
.getContextMap();
ActionProxy proxy = actionProxyFactory.createActionProxy("", "Wild-execute", null, extraContext);
// sanity: confirms the WW-5535 fix in DefaultActionProxy.resolveMethod() is wired up
assertEquals("execute", proxy.getMethod());
assertTrue("Wildcard-resolved method must report isMethodSpecified()=true", proxy.isMethodSpecified());
HttpMethodInterceptor realInterceptor = new HttpMethodInterceptor();
String result = realInterceptor.intercept(proxy.getInvocation());
// class-level @AllowedHttpMethod(POST) must still be enforced even though the resolved
// method carries no method-level annotation this is what #1690 fixed
assertEquals("bad-request", result);
}
private void prepareRequest(String httpMethod) {
MockHttpServletRequest request = new MockHttpServletRequest(httpMethod, "/action");
ActionContext.getContext().withServletRequest(request);
@@ -1,154 +0,0 @@
/*
* 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.interceptor.parameter;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.StrutsOgnlGuard;
import org.apache.struts2.ognl.StrutsProxyCacheFactory;
import org.apache.struts2.ognl.ThreadAllowlist;
import org.apache.struts2.util.StrutsProxyService;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
import static org.assertj.core.api.Assertions.assertThat;
public class OgnlParameterAllowlisterTest {
private OgnlParameterAllowlister allowlister;
private RecordingThreadAllowlist threadAllowlist;
@Before
public void setUp() {
threadAllowlist = new RecordingThreadAllowlist();
allowlister = new OgnlParameterAllowlister();
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000), LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000), LRU.toString()),
new StrutsOgnlGuard());
allowlister.setOgnlUtil(ognlUtil);
allowlister.setProxyService(new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic")));
allowlister.setThreadAllowlist(threadAllowlist);
}
@After
public void tearDown() {
threadAllowlist.clear();
}
@Test
public void depthZero_isNoOp() {
var target = new TargetWithAnnotatedNestedBean();
allowlister.primeAllowlistForPath("simple", target);
assertThat(threadAllowlist.classes).isEmpty();
}
@Test
public void nestedProperty_allowlistsPropertyType() {
var target = new TargetWithAnnotatedNestedBean();
allowlister.primeAllowlistForPath("nested.field", target);
assertThat(threadAllowlist.classes).contains(NestedBean.class);
}
@Test
public void parameterizedReturn_allowlistsTypeArguments() {
var target = new TargetWithAnnotatedNestedBean();
allowlister.primeAllowlistForPath("things[0].field", target);
assertThat(threadAllowlist.classes).contains(List.class, NestedBean.class);
}
@Test
public void publicField_isAllowlistedWhenNoGetter() {
var target = new TargetWithAnnotatedPublicField();
allowlister.primeAllowlistForPath("publicNested.field", target);
assertThat(threadAllowlist.classes).contains(NestedBean.class);
}
@Test
public void unmatchedRoot_isNoOp() {
var target = new TargetWithAnnotatedNestedBean();
allowlister.primeAllowlistForPath("unknownRoot.field", target);
assertThat(threadAllowlist.classes).isEmpty();
}
@Test
public void unannotatedNested_isNoOp() {
var target = new TargetWithUnannotatedNested();
allowlister.primeAllowlistForPath("unannotated.field", target);
assertThat(threadAllowlist.classes).isEmpty();
}
public static class TargetWithAnnotatedNestedBean {
private NestedBean nested;
private List<NestedBean> things;
@StrutsParameter(depth = 1)
public NestedBean getNested() { return nested; }
public void setNested(NestedBean nested) { this.nested = nested; }
@StrutsParameter(depth = 2)
public List<NestedBean> getThings() { return things; }
public void setThings(List<NestedBean> things) { this.things = things; }
}
public static class TargetWithAnnotatedPublicField {
@StrutsParameter(depth = 1)
public NestedBean publicNested;
}
public static class TargetWithUnannotatedNested {
private NestedBean unannotated;
public NestedBean getUnannotated() { return unannotated; }
public void setUnannotated(NestedBean v) { this.unannotated = v; }
}
public static class NestedBean {
private String field;
public String getField() { return field; }
public void setField(String f) { this.field = f; }
}
private static final class RecordingThreadAllowlist extends ThreadAllowlist {
final Set<Class<?>> classes = new HashSet<>();
@Override
public void allowClass(Class<?> clazz) {
classes.add(clazz);
super.allowClass(clazz);
}
@Override
public void allowClassHierarchy(Class<?> clazz) {
classes.add(clazz);
super.allowClassHierarchy(clazz);
}
void clear() {
classes.clear();
clearAllowlist();
}
}
}
@@ -1,129 +0,0 @@
/*
* 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.interceptor.parameter;
import org.junit.After;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class ParameterAuthorizationContextTest {
@After
public void tearDown() {
ParameterAuthorizationContext.unbind();
}
@Test
public void notActive_byDefault() {
assertThat(ParameterAuthorizationContext.isActive()).isFalse();
}
@Test
public void bind_thenActive() {
ParameterAuthorizer authorizer = (n, t, a) -> true;
Object action = new Object();
ParameterAuthorizationContext.bind(authorizer, action, action);
assertThat(ParameterAuthorizationContext.isActive()).isTrue();
}
@Test
public void unbind_clearsState() {
ParameterAuthorizer authorizer = (n, t, a) -> true;
Object action = new Object();
ParameterAuthorizationContext.bind(authorizer, action, action);
ParameterAuthorizationContext.unbind();
assertThat(ParameterAuthorizationContext.isActive()).isFalse();
}
@Test
public void isAuthorized_delegatesToBoundAuthorizer() {
Object action = new Object();
ParameterAuthorizationContext.bind((n, t, a) -> "name".equals(n), action, action);
assertThat(ParameterAuthorizationContext.isAuthorized("name")).isTrue();
assertThat(ParameterAuthorizationContext.isAuthorized("role")).isFalse();
}
@Test
public void isAuthorized_returnsTrue_whenNotActive() {
// Defensive default: no context bound = no enforcement
assertThat(ParameterAuthorizationContext.isAuthorized("anything")).isTrue();
}
@Test
public void pathStack_emptyByDefault() {
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
}
@Test
public void pushPath_buildsPrefix() {
ParameterAuthorizationContext.pushPath("address");
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
ParameterAuthorizationContext.pushPath("address.city");
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address.city");
}
@Test
public void popPath_unwinds() {
ParameterAuthorizationContext.pushPath("address");
ParameterAuthorizationContext.pushPath("address.city");
ParameterAuthorizationContext.popPath();
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
ParameterAuthorizationContext.popPath();
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
}
@Test
public void pathFor_concatenatesPropertyName() {
assertThat(ParameterAuthorizationContext.pathFor("name")).isEqualTo("name");
ParameterAuthorizationContext.pushPath("address");
assertThat(ParameterAuthorizationContext.pathFor("city")).isEqualTo("address.city");
}
@Test
public void unbind_clearsPathStack() {
ParameterAuthorizationContext.bind((n, t, a) -> true, new Object(), new Object());
ParameterAuthorizationContext.pushPath("address");
ParameterAuthorizationContext.unbind();
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
}
@Test
public void bind_replacesPriorState_doesNotResetPathStack() {
Object firstAction = new Object();
Object secondAction = new Object();
ParameterAuthorizationContext.bind((n, t, a) -> "first".equals(n), firstAction, firstAction);
ParameterAuthorizationContext.pushPath("address");
// Rebind with a different authorizer
ParameterAuthorizationContext.bind((n, t, a) -> "second".equals(n), secondAction, secondAction);
// New authorizer in effect
assertThat(ParameterAuthorizationContext.isAuthorized("first")).isFalse();
assertThat(ParameterAuthorizationContext.isAuthorized("second")).isTrue();
// Path stack is preserved across rebind (it's a separate ThreadLocal)
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
}
@Test
public void unbind_whenNeverBound_isSafeNoOp() {
// Should not throw; isActive should remain false
ParameterAuthorizationContext.unbind();
assertThat(ParameterAuthorizationContext.isActive()).isFalse();
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
}
}
@@ -1,275 +0,0 @@
/*
* 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.interceptor.parameter;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StubValueStack;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.StrutsOgnlGuard;
import org.apache.struts2.ognl.StrutsProxyCacheFactory;
import org.apache.struts2.util.StrutsProxyService;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.List;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link StrutsParameterAuthorizer} verifies that the extracted authorization logic works correctly
* without any OGNL ThreadAllowlist side effects.
*/
public class ParameterAuthorizerTest {
private StrutsParameterAuthorizer authorizer;
@Before
public void setUp() {
authorizer = new StrutsParameterAuthorizer();
authorizer.setRequireAnnotations(Boolean.TRUE.toString());
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000), LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000), LRU.toString()),
new StrutsOgnlGuard());
authorizer.setOgnlUtil(ognlUtil);
var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
authorizer.setProxyService(proxyService);
}
@After
public void tearDown() {
ActionContext.clear();
}
// --- requireAnnotations=false (backward compat) ---
@Test
public void requireAnnotationsDisabled_allAuthorized() {
authorizer.setRequireAnnotations(Boolean.FALSE.toString());
assertThat(authorizer.isAuthorized("anything", new SecureAction(), new SecureAction())).isTrue();
assertThat(authorizer.isAuthorized("unannotatedProp", new SecureAction(), new SecureAction())).isTrue();
}
// --- Simple property (depth 0) ---
@Test
public void annotatedSetter_authorized() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("name", action, action)).isTrue();
}
@Test
public void unannotatedSetter_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("role", action, action)).isFalse();
}
// --- Nested property (depth >= 1) ---
@Test
public void annotatedGetterDepthOne_nestedParam_authorized() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("address.city", action, action)).isTrue();
}
@Test
public void annotatedGetterDepthZero_nestedParam_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("addressShallow.city", action, action)).isFalse();
}
@Test
public void annotatedGetterDepthOne_doubleNested_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("address.city.zip", action, action)).isFalse();
}
// --- Public field ---
@Test
public void annotatedPublicField_authorized() {
var action = new FieldAction();
assertThat(authorizer.isAuthorized("publicStr", action, action)).isTrue();
}
@Test
public void unannotatedPublicField_rejected() {
var action = new FieldAction();
assertThat(authorizer.isAuthorized("publicStrNotAnnotated", action, action)).isFalse();
}
// --- ModelDriven exemption ---
@Test
public void modelDriven_targetIsModel_allAuthorized() {
var action = new ModelAction();
var model = action.getModel();
// target != action AND action instanceof ModelDriven model is exempt
assertThat(authorizer.isAuthorized("anyProperty", model, action)).isTrue();
assertThat(authorizer.isAuthorized("nested.deep", model, action)).isTrue();
}
@Test
public void nonModelDrivenAction_differentTarget_notExempt() {
// Regression test: when target != action but action does NOT implement ModelDriven,
// the target should NOT be exempt from annotation checks.
var action = new SecureAction();
var nonActionTarget = new Pojo(); // different object, but action is not ModelDriven
// Pojo has no @StrutsParameter annotations, so this should be rejected
assertThat(authorizer.isAuthorized("name", nonActionTarget, action)).isFalse();
}
// --- Transition mode ---
@Test
public void transitionMode_depthZeroExempt() {
authorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
var action = new SecureAction();
// depth-0 unannotated property should be exempt
assertThat(authorizer.isAuthorized("role", action, action)).isTrue();
}
@Test
public void transitionMode_depthOneNotExempt() {
authorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
var action = new SecureAction();
// depth-1 unannotated property should NOT be exempt
assertThat(authorizer.isAuthorized("unannotatedNested.prop", action, action)).isFalse();
}
// --- No matching member ---
@Test
public void nonexistentProperty_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("doesNotExist", action, action)).isFalse();
}
// --- Empty/null parameter name ---
@Test
public void nullParameterName_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized(null, action, action)).isFalse();
}
@Test
public void emptyParameterName_rejected() {
var action = new SecureAction();
assertThat(authorizer.isAuthorized("", action, action)).isFalse();
}
@Test
public void emptyParameterName_rejectedEvenWhenAnnotationsNotRequired() {
authorizer.setRequireAnnotations(Boolean.FALSE.toString());
var action = new SecureAction();
assertThat(authorizer.isAuthorized("", action, action)).isFalse();
assertThat(authorizer.isAuthorized(null, action, action)).isFalse();
}
// --- resolveTarget ---
@Test
public void resolveTarget_nonModelDriven_returnsAction() {
var action = new SecureAction();
assertThat(authorizer.resolveTarget(action)).isSameAs(action);
}
@Test
public void resolveTarget_modelDriven_returnsModelFromValueStack() {
var action = new ModelAction();
var model = action.getModel();
var valueStack = new StubValueStack();
valueStack.push(model);
ActionContext.of().withValueStack(valueStack).bind();
assertThat(authorizer.resolveTarget(action)).isSameAs(model);
}
@Test
public void resolveTarget_modelDriven_stackTopEqualsAction_returnsAction() {
// Edge case: ModelDriven action where stack top equals the action itself.
// No exemption applies target stays as action.
var action = new ModelAction();
var valueStack = new StubValueStack();
valueStack.push(action);
ActionContext.of().withValueStack(valueStack).bind();
assertThat(authorizer.resolveTarget(action)).isSameAs(action);
}
// --- Inner test classes ---
public static class SecureAction {
private String name;
private String role;
private Address address;
private Address addressShallow;
@StrutsParameter
public void setName(String name) { this.name = name; }
public String getName() { return name; }
// NO @StrutsParameter must be rejected
public void setRole(String role) { this.role = role; }
public String getRole() { return role; }
@StrutsParameter(depth = 1)
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
@StrutsParameter
public Address getAddressShallow() { return addressShallow; }
public void setAddressShallow(Address address) { this.addressShallow = address; }
// Unannotated getter for nested param test
public Object getUnannotatedNested() { return null; }
}
public static class Address {
private String city;
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
}
public static class FieldAction {
@StrutsParameter
public String publicStr;
public String publicStrNotAnnotated;
}
public static class ModelAction implements ModelDriven<Pojo> {
@Override
public Pojo getModel() { return new Pojo(); }
}
public static class Pojo {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
}
@@ -42,7 +42,7 @@ import org.apache.struts2.ognl.accessor.RootAccessor;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.ValueStackFactory;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.OgnlContext;
import org.apache.struts2.ognl.StrutsContext;
import org.apache.struts2.action.NoParameters;
import org.apache.struts2.action.ParameterNameAware;
import org.apache.struts2.action.ParameterValueAware;
@@ -353,7 +353,7 @@ public class ParametersInterceptorTest extends XWorkTestCase {
//then
assertEquals("This is blah", ((SimpleAction) proxy.getAction()).getBlah());
Field field = ReflectionContextState.class.getField("DENY_METHOD_EXECUTION");
boolean allowStaticFieldAccess = ((OgnlContext) stack.getContext()).getMemberAccess().isAccessible((OgnlContext) stack.getContext(), ReflectionContextState.class, field, "");
boolean allowStaticFieldAccess = ((StrutsContext) stack.getContext()).getMemberAccess().isAccessible((StrutsContext) stack.getContext(), ReflectionContextState.class, field, "");
assertFalse(allowStaticFieldAccess);
}
@@ -56,7 +56,6 @@ import static org.mockito.Mockito.when;
public class StrutsParameterAnnotationTest {
private ParametersInterceptor parametersInterceptor;
private StrutsParameterAuthorizer parameterAuthorizer;
private ThreadAllowlist threadAllowlist;
@@ -77,19 +76,6 @@ public class StrutsParameterAnnotationTest {
var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
parametersInterceptor.setProxyService(proxyService);
var parameterAuthorizer = new StrutsParameterAuthorizer();
parameterAuthorizer.setOgnlUtil(ognlUtil);
parameterAuthorizer.setProxyService(proxyService);
parameterAuthorizer.setRequireAnnotations(Boolean.TRUE.toString());
this.parameterAuthorizer = parameterAuthorizer;
parametersInterceptor.setParameterAuthorizer(parameterAuthorizer);
var parameterAllowlister = new OgnlParameterAllowlister();
parameterAllowlister.setOgnlUtil(ognlUtil);
parameterAllowlister.setProxyService(proxyService);
parameterAllowlister.setThreadAllowlist(threadAllowlist);
parametersInterceptor.setParameterAllowlister(parameterAllowlister);
NotExcludedAcceptedPatternsChecker checker = mock(NotExcludedAcceptedPatternsChecker.class);
when(checker.isAccepted(anyString())).thenReturn(IsAccepted.yes(""));
when(checker.isExcluded(anyString())).thenReturn(IsExcluded.no(Set.of()));
@@ -374,7 +360,6 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotated_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new FieldAction(), "publicStrNotAnnotated", true);
}
@@ -384,7 +369,6 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotatedMethod_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new MethodAction(), "publicStrNotAnnotated", true);
}
@@ -23,7 +23,6 @@ import ognl.MethodFailedException;
import ognl.NoSuchPropertyException;
import ognl.NullHandler;
import ognl.Ognl;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.OgnlRuntime;
import ognl.SimpleNode;
@@ -90,12 +89,12 @@ public class OgnlUtilTest extends XWorkTestCase {
public void testCanSetADependentObject() {
String dogName = "fido";
OgnlRuntime.setNullHandler(Owner.class, new NullHandler() {
public Object nullMethodResult(OgnlContext context, Object o, String s, Object[] objects) {
OgnlRuntime.setNullHandler(Owner.class, new NullHandler<StrutsContext>() {
public Object nullMethodResult(StrutsContext context, Object o, String s, Object[] objects) {
return null;
}
public Object nullPropertyValue(OgnlContext context, Object o, Object o1) {
public Object nullPropertyValue(StrutsContext context, Object o, Object o1) {
String methodName = o1.toString();
String getter = "set" + methodName.substring(0, 1).toUpperCase() + methodName.substring(1);
Method[] methods = o.getClass().getDeclaredMethods();
@@ -199,7 +198,7 @@ public class OgnlUtilTest extends XWorkTestCase {
public void testExpressionIsCachedIrrespectiveOfItsExecutionStatus() {
Foo foo = new Foo();
OgnlContext context = ognlUtil.createDefaultContext(foo);
StrutsContext context = ognlUtil.createDefaultContext(foo);
// Expression which executes with success
try {
@@ -223,7 +222,7 @@ public class OgnlUtilTest extends XWorkTestCase {
ognlUtil.setContainer(container); // Must be explicitly set as the generated OgnlUtil instance has no container
ognlUtil.setEnableExpressionCache("true");
Foo foo = new Foo();
OgnlContext context = ognlUtil.createDefaultContext(foo);
StrutsContext context = ognlUtil.createDefaultContext(foo);
// Expression which executes with success
try {
@@ -243,7 +242,7 @@ public class OgnlUtilTest extends XWorkTestCase {
public void testMethodExpressionIsCachedIrrespectiveOfItsExecutionStatus() {
Foo foo = new Foo();
OgnlContext context = ognlUtil.createDefaultContext(foo);
StrutsContext context = ognlUtil.createDefaultContext(foo);
// Method expression which executes with success
try {
@@ -846,7 +845,7 @@ public class OgnlUtilTest extends XWorkTestCase {
ChainingInterceptor foo = new ChainingInterceptor();
ChainingInterceptor foo2 = new ChainingInterceptor();
OgnlContext context = ognlUtil.createDefaultContext(null);
StrutsContext context = ognlUtil.createDefaultContext(null);
SimpleNode expression = (SimpleNode) Ognl.parseExpression("{'a','ruby','b','tom'}");
Ognl.getValue(expression, context, "aksdj");
@@ -903,7 +902,7 @@ public class OgnlUtilTest extends XWorkTestCase {
public void testBeanMapExpressions() throws OgnlException, NoSuchMethodException {
Foo foo = new Foo();
OgnlContext context = ognlUtil.createDefaultContext(foo);
StrutsContext context = ognlUtil.createDefaultContext(foo);
SecurityMemberAccess sma = (SecurityMemberAccess) context.getMemberAccess();
sma.useExcludedPackageNames("org.apache.struts2.ognl");
@@ -19,7 +19,6 @@
package org.apache.struts2.ognl;
import ognl.MemberAccess;
import ognl.OgnlContext;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.struts2.TestBean;
import org.apache.struts2.config.ConfigurationException;
@@ -55,7 +54,7 @@ import static org.mockito.Mockito.when;
public class SecurityMemberAccessTest {
private OgnlContext context;
private StrutsContext context;
private FooBar target;
protected SecurityMemberAccess sma;
protected ProviderAllowlist mockedProviderAllowlist;
@@ -64,12 +63,12 @@ public class SecurityMemberAccessTest {
@Before
public void setUp() {
context = ognl.Ognl.createDefaultContext(null);
target = new FooBar();
mockedProviderAllowlist = mock(ProviderAllowlist.class);
mockedThreadAllowlist = mock(ThreadAllowlist.class);
proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
assignNewSma(true);
context = new StrutsContext(sma);
}
protected void assignNewSma(boolean allowStaticFieldAccess) {
@@ -37,7 +37,6 @@ import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.location.LocatableProperties;
import org.apache.struts2.util.reflection.ReflectionContextState;
import ognl.Ognl;
import ognl.OgnlContext;
import java.util.ArrayList;
import java.util.Collection;
@@ -57,7 +56,8 @@ public class SetPropertiesTest extends XWorkTestCase {
public void testOgnlUtilEmptyStringAsLong() {
Bar bar = new Bar();
OgnlContext context = Ognl.createDefaultContext(bar, new SecurityMemberAccess(null, null));
StrutsContext context = new StrutsContext(new SecurityMemberAccess(null, null));
context.withRoot(bar);
context.put(XWorkConverter.REPORT_CONVERSION_ERRORS, Boolean.TRUE);
bar.setId(null);
@@ -81,7 +81,7 @@ public class SetPropertiesTest extends XWorkTestCase {
ValueStack vs = ActionContext.getContext().getValueStack();
vs.getContext().put(XWorkConverter.REPORT_CONVERSION_ERRORS, Boolean.TRUE);
XWorkConverter c = (XWorkConverter) ((OgnlTypeConverterWrapper) Ognl.getTypeConverter((OgnlContext) vs.getContext())).getTarget();
XWorkConverter c = (XWorkConverter) ((OgnlTypeConverterWrapper) ((StrutsContext) vs.getContext()).getTypeConverter()).getTarget();
c.registerConverter(Cat.class.getName(), new FooBarConverter());
vs.push(foo);
@@ -97,7 +97,7 @@ public class SetPropertiesTest extends XWorkTestCase {
ValueStack vs = ActionContext.getContext().getValueStack();
vs.getContext().put(XWorkConverter.REPORT_CONVERSION_ERRORS, Boolean.TRUE);
XWorkConverter c = (XWorkConverter) ((OgnlTypeConverterWrapper) Ognl.getTypeConverter((OgnlContext) vs.getContext())).getTarget();
XWorkConverter c = (XWorkConverter) ((OgnlTypeConverterWrapper) ((StrutsContext) vs.getContext()).getTypeConverter()).getTarget();
c.registerConverter(Cat.class.getName(), new FooBarConverter());
vs.push(foo);
@@ -0,0 +1,72 @@
/*
* 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.ognl;
import ognl.ClassResolver;
import ognl.MemberAccess;
import ognl.TypeConverter;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@SuppressWarnings("unchecked")
public class StrutsContextTest {
@Test
public void shouldCreateContextWithRequiredMemberAccess() {
MemberAccess<StrutsContext> memberAccess = mock(MemberAccess.class);
var context = new StrutsContext(memberAccess);
assertThat(context).isNotNull();
assertThat(context.getMemberAccess()).isSameAs(memberAccess);
}
@Test
public void shouldCreateContextWithAllComponents() {
MemberAccess<StrutsContext> memberAccess = mock(MemberAccess.class);
ClassResolver<StrutsContext> classResolver = mock(ClassResolver.class);
TypeConverter<StrutsContext> typeConverter = mock(TypeConverter.class);
var context = new StrutsContext(memberAccess, classResolver, typeConverter);
assertThat(context.getMemberAccess()).isSameAs(memberAccess);
assertThat(context.getClassResolver()).isSameAs(classResolver);
assertThat(context.getTypeConverter()).isSameAs(typeConverter);
}
@Test
public void shouldSupportRootObject() {
MemberAccess<StrutsContext> memberAccess = mock(MemberAccess.class);
var root = new Object();
var context = new StrutsContext(memberAccess);
context.withRoot(root);
assertThat(context.getRoot()).isSameAs(root);
}
@Test
public void shouldImplementMapInterface() {
MemberAccess<StrutsContext> memberAccess = mock(MemberAccess.class);
var context = new StrutsContext(memberAccess);
context.put("testKey", "testValue");
assertThat(context.get("testKey")).isEqualTo("testValue");
}
}
@@ -145,108 +145,5 @@ public class PostbackResultTest extends StrutsInternalTestCase {
}
}
/**
* WW-5623: Verify that HTML special characters in finalLocation are properly
* escaped in the rendered form action attribute to prevent XSS.
*/
public void testFormActionHtmlEscaping() throws Exception {
ActionContext context = ActionContext.getContext();
ValueStack stack = context.getValueStack();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
// Push an object with a malicious property onto the value stack
stack.push(new Object() {
public String getTargetUrl() {
return "/test\"onmouseover=\"alert(1)";
}
});
PostbackResult result = new PostbackResult();
result.setLocation("/redirect?url=${targetUrl}");
result.setPrependServletContext(false);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
expect(mockInvocation.getStack()).andReturn(stack).anyTimes();
control.replay();
result.setActionMapper(container.getInstance(ActionMapper.class));
// Call doExecute directly with a malicious location containing all critical chars
result.doExecute("/test\"onmouseover=\"alert(1)\"&param=<script>", mockInvocation);
String output = res.getContentAsString();
// The action attribute must contain escaped HTML entities
assertTrue("Double quote should be escaped to &quot;",
output.contains("action=\"/test&quot;onmouseover=&quot;alert(1)&quot;&amp;param=&lt;script&gt;\""));
// Must not contain unescaped double-quote that breaks out of the attribute
assertFalse("Raw double-quote must not appear in action value",
output.contains("action=\"/test\""));
control.verify();
}
/**
* WW-5623: Verify that each individual HTML special character is properly escaped.
*/
public void testFormActionEscapesAllHtmlSpecialChars() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
control.replay();
PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/path?a=1&b=2\"<>", mockInvocation);
String output = res.getContentAsString();
assertTrue("Ampersand should be escaped", output.contains("&amp;"));
assertTrue("Double-quote should be escaped", output.contains("&quot;"));
assertTrue("Less-than should be escaped", output.contains("&lt;"));
assertTrue("Greater-than should be escaped", output.contains("&gt;"));
control.verify();
}
/**
* WW-5623: Verify that a clean location (no special chars) renders unchanged.
*/
public void testFormActionCleanLocationUnchanged() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
control.replay();
PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/clean/path/action.do", mockInvocation);
String output = res.getContentAsString();
assertTrue("Clean location should render as-is in action attribute",
output.contains("action=\"/clean/path/action.do\""));
control.verify();
}
}
@@ -47,7 +47,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static jakarta.servlet.http.HttpServletResponse.SC_SEE_OTHER;
import static org.easymock.EasyMock.createControl;
import static org.easymock.EasyMock.createMock;
@@ -144,33 +143,6 @@ public class ServletRedirectResultTest extends StrutsInternalTestCase implements
assertEquals("/context/bar/foo.jsp", writer.toString());
}
public void testStatusCode200LocationIsHtmlEscapedInBody() {
String maliciousLocation = "/bar/foo.jsp?next=<script>alert(1)</script>&x=1";
String expandedLocation = "/context" + maliciousLocation;
String expectedBody = "/context/bar/foo.jsp?next=&lt;script&gt;alert(1)&lt;/script&gt;&amp;x=1";
view.setLocation(maliciousLocation);
view.setStatusCode(SC_OK);
responseMock.expectAndReturn("encodeRedirectURL", expandedLocation, expandedLocation);
responseMock.expect("setStatus", C.args(C.eq(SC_OK)));
responseMock.expect("setHeader", C.args(C.eq("Location"), C.eq(expandedLocation)));
StringWriter writer = new StringWriter();
responseMock.matchAndReturn("getWriter", new PrintWriter(writer));
try {
view.execute(ai);
requestMock.verify();
responseMock.verify();
} catch (Exception e) {
e.printStackTrace();
fail();
}
assertEquals(expectedBody, writer.toString());
assertFalse("response body must not contain a raw <script> tag",
writer.toString().contains("<script>"));
}
public void testAbsoluteRedirectAnchor() {
view.setLocation("/bar/foo.jsp");
view.setAnchor("fragment");
@@ -1,255 +0,0 @@
/*
* 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.util;
import com.github.benmanes.caffeine.cache.Cache;
import org.apache.struts2.config.ConfigurationException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class ConfigParseUtilTest {
@Before
public void setUp() {
validatedClassCache().invalidateAll();
}
@After
public void tearDown() {
validatedClassCache().invalidateAll();
}
/**
* (a) Single-loader caching: one loader validates several distinct classes; repeating the call
* loads each class exactly once. Covers both "repeated calls hit the cache" and "the inner cache
* is keyed per class name".
*/
@Test
public void testSameLoaderCachesEachDistinctClassOnce() {
CountingClassLoader loader = new CountingClassLoader(getClass().getClassLoader(), "single-loader");
Set<String> classNames = new HashSet<>();
classNames.add(String.class.getName());
classNames.add(Integer.class.getName());
classNames.add(Boolean.class.getName());
ConfigParseUtil.validateClasses(classNames, loader);
ConfigParseUtil.validateClasses(classNames, loader);
assertEquals(1, loader.getLoadCount(String.class.getName()));
assertEquals(1, loader.getLoadCount(Integer.class.getName()));
assertEquals(1, loader.getLoadCount(Boolean.class.getName()));
}
/**
* (b) Per-loader isolation: the outer cache is keyed by classloader identity, not by toString().
* Two loaders that share the same toString() each load the class once, and re-validating one
* loader still hits its own cache.
*/
@Test
public void testDifferentLoadersWithSameNameCacheIndependently() {
CountingClassLoader firstLoader = new CountingClassLoader(getClass().getClassLoader(), "same-name");
CountingClassLoader secondLoader = new CountingClassLoader(getClass().getClassLoader(), "same-name");
Set<String> classNames = Collections.singleton(String.class.getName());
ConfigParseUtil.validateClasses(classNames, firstLoader);
ConfigParseUtil.validateClasses(classNames, secondLoader);
assertEquals(1, firstLoader.getStringClassLoads());
assertEquals(1, secondLoader.getStringClassLoads());
// Re-validating the first loader still hits its own cache.
ConfigParseUtil.validateClasses(classNames, firstLoader);
assertEquals(1, firstLoader.getStringClassLoads());
}
/**
* Negative case: a missing class throws ConfigurationException (cause ClassNotFoundException) on
* every call, and the failure is not cached (each call re-attempts the load).
*/
@Test
public void testMissingClassThrowsAndIsNotCached() {
String missingClassName = "org.apache.struts2.util.NonExistingClassForValidationTest";
Set<String> classNames = Collections.singleton(missingClassName);
int[] missingClassLoads = new int[1];
ClassLoader loader = new ClassLoader(getClass().getClassLoader()) {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (missingClassName.equals(name)) {
missingClassLoads[0]++;
throw new ClassNotFoundException(name);
}
return super.loadClass(name);
}
@Override
public String toString() {
return "missing-class-loader";
}
};
for (int i = 0; i < 2; i++) {
try {
ConfigParseUtil.validateClasses(classNames, loader);
fail("Expected ConfigurationException for class: " + missingClassName);
} catch (ConfigurationException e) {
assertTrue(e.getMessage().contains(missingClassName));
assertNotNull(e.getCause());
assertEquals(ClassNotFoundException.class, e.getCause().getClass());
}
}
assertEquals(2, missingClassLoads[0]);
}
/**
* (c) Outer cache bound: registering more classloaders than the maximum keeps the outer cache at
* or below its configured size.
*/
@Test
public void testOuterCacheBoundedByMaxClassloaders() {
Set<String> classNames = Collections.singleton(String.class.getName());
for (int i = 0; i < outerCacheLimit() + 10; i++) {
CountingClassLoader loader = new CountingClassLoader(getClass().getClassLoader(), "loader-" + i);
ConfigParseUtil.validateClasses(classNames, loader);
}
Cache<Object, Object> cache = validatedClassCache();
cache.cleanUp();
assertTrue("Outer cache size should not exceed configured maximum",
cache.estimatedSize() <= outerCacheLimit());
}
/**
* (c) Inner cache bound: validating more class names than the per-loader maximum keeps that
* loader's inner cache at or below its configured size. Synthetic names are resolved to a real
* class so the count is driven by distinct keys, not by which JDK classes happen to exist.
*/
@Test
public void testInnerCacheBoundedByMaxClassesPerLoader() {
int limit = innerCacheLimit();
ClassLoader loader = new ClassLoader(getClass().getClassLoader()) {
@Override
public Class<?> loadClass(String name) {
// Resolve any synthetic name to a strongly-reachable class so weakValues never evicts it.
return Object.class;
}
@Override
public String toString() {
return "inner-bound-loader";
}
};
Set<String> classNames = new LinkedHashSet<>();
for (int i = 0; i <= limit + 10; i++) {
classNames.add("synthetic.Class" + i);
}
assertTrue("Test must request more class names than the inner cache capacity",
classNames.size() > limit);
ConfigParseUtil.validateClasses(classNames, loader);
Cache<Object, Object> innerCache = innerCacheFor(loader);
innerCache.cleanUp();
assertTrue("Inner cache size should not exceed configured maximum per loader",
innerCache.estimatedSize() <= limit);
}
@SuppressWarnings("unchecked")
private static Cache<Object, Object> validatedClassCache() {
try {
Field cacheField = ConfigParseUtil.class.getDeclaredField("VALIDATED_CLASS_CACHE");
cacheField.setAccessible(true);
return (Cache<Object, Object>) cacheField.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new AssertionError("Cannot access ConfigParseUtil cache field", e);
}
}
@SuppressWarnings("unchecked")
private static Cache<Object, Object> innerCacheFor(ClassLoader loader) {
Cache<Object, Object> outer = validatedClassCache();
Object inner = outer.getIfPresent(loader);
assertNotNull("Expected an inner cache entry for loader", inner);
return (Cache<Object, Object>) inner;
}
private static int outerCacheLimit() {
return intConstant("MAX_CLASSLOADER_CACHE_SIZE");
}
private static int innerCacheLimit() {
return intConstant("MAX_CLASS_CACHE_PER_LOADER_SIZE");
}
private static int intConstant(String fieldName) {
try {
Field field = ConfigParseUtil.class.getDeclaredField(fieldName);
field.setAccessible(true);
return field.getInt(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new AssertionError("Cannot access ConfigParseUtil constant: " + fieldName, e);
}
}
private static final class CountingClassLoader extends ClassLoader {
private final String loaderName;
private final Map<String, Integer> loadCounts = new HashMap<>();
private CountingClassLoader(ClassLoader parent, String loaderName) {
super(parent);
this.loaderName = loaderName;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
loadCounts.merge(name, 1, Integer::sum);
return super.loadClass(name);
}
private int getStringClassLoads() {
return loadCounts.getOrDefault(String.class.getName(), 0);
}
private int getLoadCount(String className) {
return loadCounts.getOrDefault(className, 0);
}
@Override
public String toString() {
return loaderName;
}
}
}
@@ -19,8 +19,8 @@
package org.apache.struts2.util;
import org.apache.struts2.ognl.SecurityMemberAccess;
import org.apache.struts2.ognl.StrutsContext;
import jakarta.servlet.jsp.tagext.TagSupport;
import ognl.OgnlContext;
import org.apache.struts2.StrutsInternalTestCase;
import org.apache.struts2.views.jsp.ActionTag;
@@ -28,12 +28,12 @@ import java.lang.reflect.Member;
public class SecurityMemberAccessInServletsTest extends StrutsInternalTestCase {
private OgnlContext context;
private StrutsContext context;
@Override
public void setUp() throws Exception {
super.setUp();
context = ognl.Ognl.createDefaultContext(null);
context = new StrutsContext(new SecurityMemberAccess(null, null));
}
public void testJavaxServletPackageAccess() throws Exception {
@@ -131,6 +131,7 @@ public class FreemarkerManagerTest extends StrutsInternalTestCase {
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("false");
manager.setDevMode("false");
// when
manager.init(servletContext);
@@ -138,6 +139,34 @@ public class FreemarkerManagerTest extends StrutsInternalTestCase {
// then
assertFalse(manager.config.getWhitespaceStripping());
}
public void testWhitespaceStrippingDisabledInDevMode() throws Exception {
// given
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("true");
manager.setDevMode("true");
// when
manager.init(servletContext);
// then
assertFalse(manager.config.getWhitespaceStripping());
}
public void testWhitespaceStrippingEnabledWhenNotInDevMode() throws Exception {
// given
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("true");
manager.setDevMode("false");
// when
manager.init(servletContext);
// then
assertTrue(manager.config.getWhitespaceStripping());
}
}
class DummyFreemarkerManager extends FreemarkerManager {
@@ -0,0 +1,272 @@
# Hibernate Proxy Detection Optimization
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate `LinkageError` exceptions thrown when Hibernate is not on the classpath by detecting availability once at class-load time.
**Architecture:** Add a static availability check in `StrutsProxyService` that probes for `org.hibernate.proxy.HibernateProxy` once during class initialization. All Hibernate-related methods short-circuit immediately when Hibernate is absent. Same pattern applied to deprecated `ProxyUtil`.
**Tech Stack:** Java 17, JUnit 5, AssertJ, Mockito
---
### Task 1: Add Hibernate Availability Check to StrutsProxyService
**Files:**
- Modify: `core/src/main/java/org/apache/struts2/util/StrutsProxyService.java`
- Test: `core/src/test/java/org/apache/struts2/util/StrutsProxyServiceTest.java`
- [ ] **Step 1: Write the failing test — verify no LinkageError is thrown when Hibernate classes are used**
The existing tests already call `isHibernateProxy()` and `isHibernateProxyMember()` with non-Hibernate objects. We need a test that verifies the short-circuit behavior works correctly. Add this test to `StrutsProxyServiceTest.java`:
```java
@Test
public void isHibernateProxyDoesNotThrowWhenCalledRepeatedly() {
// Verify that calling isHibernateProxy many times for different objects
// does not cause performance issues (no exceptions thrown internally)
for (int i = 0; i < 1000; i++) {
assertThat(proxyService.isHibernateProxy(new Object())).isFalse();
}
}
@Test
public void isHibernateProxyMemberDoesNotThrowWhenCalledRepeatedly() throws NoSuchMethodException {
Method method = Object.class.getMethod("toString");
for (int i = 0; i < 1000; i++) {
assertThat(proxyService.isHibernateProxyMember(method)).isFalse();
}
}
```
- [ ] **Step 2: Run tests to verify they pass (baseline — these pass even without the fix because Hibernate IS on the test classpath)**
Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsProxyServiceTest#isHibernateProxyDoesNotThrowWhenCalledRepeatedly+isHibernateProxyMemberDoesNotThrowWhenCalledRepeatedly`
Expected: PASS
- [ ] **Step 3: Add static Hibernate availability flag to StrutsProxyService**
In `core/src/main/java/org/apache/struts2/util/StrutsProxyService.java`, add a static availability check at the top of the class and modify the three Hibernate methods to short-circuit:
```java
// Add this field near the top of the class, after the class declaration:
private static final boolean HIBERNATE_AVAILABLE = isHibernateAvailable();
private static boolean isHibernateAvailable() {
try {
Class.forName("org.hibernate.proxy.HibernateProxy");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
```
Then modify the three Hibernate methods to short-circuit:
**`isHibernateProxy`** — change from:
```java
@Override
public boolean isHibernateProxy(Object object) {
try {
return object != null && HibernateProxy.class.isAssignableFrom(object.getClass());
} catch (LinkageError ignored) {
return false;
}
}
```
to:
```java
@Override
public boolean isHibernateProxy(Object object) {
if (!HIBERNATE_AVAILABLE || object == null) {
return false;
}
try {
return HibernateProxy.class.isAssignableFrom(object.getClass());
} catch (LinkageError ignored) {
return false;
}
}
```
**`isHibernateProxyMember`** — change from:
```java
@Override
public boolean isHibernateProxyMember(Member member) {
try {
return hasMember(HibernateProxy.class, member);
} catch (LinkageError ignored) {
return false;
}
}
```
to:
```java
@Override
public boolean isHibernateProxyMember(Member member) {
if (!HIBERNATE_AVAILABLE) {
return false;
}
try {
return hasMember(HibernateProxy.class, member);
} catch (LinkageError ignored) {
return false;
}
}
```
**`getHibernateProxyTarget`** — change from:
```java
@Override
public Object getHibernateProxyTarget(Object object) {
try {
return Hibernate.unproxy(object);
} catch (LinkageError ignored) {
return object;
}
}
```
to:
```java
@Override
public Object getHibernateProxyTarget(Object object) {
if (!HIBERNATE_AVAILABLE) {
return object;
}
try {
return Hibernate.unproxy(object);
} catch (LinkageError ignored) {
return object;
}
}
```
- [ ] **Step 4: Run the full StrutsProxyService test suite**
Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsProxyServiceTest`
Expected: All tests PASS
- [ ] **Step 5: Run the Spring integration test suite**
Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsProxyServiceSpringIntegrationTest`
Expected: All tests PASS
- [ ] **Step 6: Commit**
```bash
git add core/src/main/java/org/apache/struts2/util/StrutsProxyService.java core/src/test/java/org/apache/struts2/util/StrutsProxyServiceTest.java
git commit -m "WW-5622 Optimize Hibernate proxy detection to avoid LinkageError exceptions
Add static availability check for Hibernate classes in StrutsProxyService.
When Hibernate is not on the classpath, all Hibernate-related methods
short-circuit immediately without throwing/catching LinkageError.
This eliminates a significant performance penalty for applications
that don't use Hibernate."
```
---
### Task 2: Apply Same Fix to Deprecated ProxyUtil
**Files:**
- Modify: `core/src/main/java/org/apache/struts2/util/ProxyUtil.java`
- [ ] **Step 1: Add the same static availability check to ProxyUtil**
In `core/src/main/java/org/apache/struts2/util/ProxyUtil.java`, add the same pattern:
```java
// Add after the isProxyMemberCache field:
private static final boolean HIBERNATE_AVAILABLE = isHibernateAvailable();
private static boolean isHibernateAvailable() {
try {
Class.forName("org.hibernate.proxy.HibernateProxy");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
```
Then modify the three Hibernate methods in ProxyUtil identically to Task 1:
**`isHibernateProxy`**:
```java
@Deprecated(since = "7.2")
public static boolean isHibernateProxy(Object object) {
if (!HIBERNATE_AVAILABLE || object == null) {
return false;
}
try {
return HibernateProxy.class.isAssignableFrom(object.getClass());
} catch (LinkageError ignored) {
return false;
}
}
```
**`isHibernateProxyMember`**:
```java
@Deprecated(since = "7.2")
public static boolean isHibernateProxyMember(Member member) {
if (!HIBERNATE_AVAILABLE) {
return false;
}
try {
return hasMember(HibernateProxy.class, member);
} catch (LinkageError ignored) {
return false;
}
}
```
**`getHibernateProxyTarget`**:
```java
@Deprecated(since = "7.2")
public static Object getHibernateProxyTarget(Object object) {
if (!HIBERNATE_AVAILABLE) {
return object;
}
try {
return Hibernate.unproxy(object);
} catch (LinkageError ignored) {
return object;
}
}
```
- [ ] **Step 2: Run existing ProxyUtil tests**
Run: `mvn test -DskipAssembly -pl core -Dtest=ProxyUtilTest`
Expected: PASS (or if no dedicated test exists, run the SecurityMemberAccess tests which exercise ProxyUtil indirectly)
Run: `mvn test -DskipAssembly -pl core -Dtest=SecurityMemberAccessTest`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add core/src/main/java/org/apache/struts2/util/ProxyUtil.java
git commit -m "WW-5622 Apply same Hibernate availability optimization to deprecated ProxyUtil"
```
---
### Task 3: Run Full Test Suite
- [ ] **Step 1: Run all core tests**
Run: `mvn test -DskipAssembly -pl core`
Expected: All tests PASS
- [ ] **Step 2: Run spring plugin tests (exercises proxy detection heavily)**
Run: `mvn test -DskipAssembly -pl plugins/spring`
Expected: All tests PASS
- [ ] **Step 3: Run json plugin tests (StrutsJSONWriter has Hibernate-related class name checks)**
Run: `mvn test -DskipAssembly -pl plugins/json`
Expected: All tests PASS
File diff suppressed because it is too large Load Diff
@@ -1,392 +0,0 @@
# commons-fileupload2 Milestone Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make a `commons-fileupload2-core` / `-jakarta-servlet6` version skew impossible in Struts's own build and turn a future runtime `NoSuchMethodError` into a clear, actionable `StrutsException`.
**Architecture:** Three independent changes. (A1) Introduce a single `commons-fileupload2.version` property and manage *both* fileupload artifacts in `parent/pom.xml`. (A2) Activate the dormant `maven-enforcer-plugin` with a fileupload-scoped `bannedDependencies` rule. (B) Add a once-per-JVM reflective API guard in `AbstractMultiPartRequest`.
**Tech Stack:** Maven (multi-module), `maven-enforcer-plugin` 3.6.3, Java 17, JUnit 4 + AssertJ (the `core` module's established test stack), Apache Commons FileUpload 2.0.0-M5.
**Ticket:** [WW-5632](https://issues.apache.org/jira/browse/WW-5632)
**Spec:** `docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md`
**Branch:** `WW-5632-fileupload2-milestone-hardening` (already checked out)
---
## File Structure
- `pom.xml` (root) — add the `commons-fileupload2.version` property; change the enforcer rule from `dependencyConvergence` to a scoped `bannedDependencies`; bind the enforcer into the active `<plugins>` section.
- `parent/pom.xml` — reference the new property for `commons-fileupload2-jakarta-servlet6` and add a managed entry for `commons-fileupload2-core`.
- `core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java` — add the runtime API guard and call it from `prepareServletFileUpload`.
- `core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java` (new) — unit tests for the guard.
---
## Task 1: Manage both fileupload artifacts via a single version property (A1)
**Files:**
- Modify: `pom.xml:118-119` (properties block)
- Modify: `parent/pom.xml:128-132` (dependencyManagement entry)
- [ ] **Step 1: Add the version property to the root POM**
In `pom.xml`, inside the `<properties>` block, add the property in alphabetical order between `byte-buddy.version` (line 118) and `freemarker.version` (line 119):
```xml
<byte-buddy.version>1.18.8</byte-buddy.version>
<commons-fileupload2.version>2.0.0-M5</commons-fileupload2.version>
<freemarker.version>2.3.34</freemarker.version>
```
- [ ] **Step 2: Reference the property and add the `-core` managed entry**
In `parent/pom.xml`, replace the existing single `commons-fileupload2-jakarta-servlet6` management entry (currently lines 128-132):
```xml
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
<version>2.0.0-M5</version>
</dependency>
```
with two entries, both referencing the property (the volatile API lives in `-core`, so it must be pinned too):
```xml
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-fileupload2-core</artifactId>
<version>${commons-fileupload2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
<version>${commons-fileupload2.version}</version>
</dependency>
```
- [ ] **Step 3: Verify both artifacts resolve to the pinned version**
Run:
```bash
mvn -q -pl core dependency:list -DskipAssembly '-Dincludes=org.apache.commons:commons-fileupload2*'
```
Expected: both `commons-fileupload2-core` and `commons-fileupload2-jakarta-servlet6` listed at `2.0.0-M5`.
- [ ] **Step 4: Verify the reactor still builds**
Run:
```bash
mvn -q validate -DskipAssembly
```
Expected: `BUILD SUCCESS` (no errors from the new property / managed dependency).
- [ ] **Step 5: Commit**
```bash
git add pom.xml parent/pom.xml
git commit -m "WW-5632 build(deps): manage commons-fileupload2-core alongside jakarta-servlet6
Pin both commons-fileupload2 artifacts to a single
commons-fileupload2.version property so the volatile -core API can no
longer skew from -jakarta-servlet6 in the reactor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Activate a fileupload-scoped enforcer rule (A2)
**Files:**
- Modify: `pom.xml:349-353` (enforcer rule config in `<pluginManagement>`)
- Modify: `pom.xml:373-378` (active `<plugins>` section)
- [ ] **Step 1: Replace the dormant `dependencyConvergence` rule with a scoped `bannedDependencies` rule**
In `pom.xml`, inside the `maven-enforcer-plugin` execution in `<pluginManagement>`, replace the current configuration (lines 349-353):
```xml
<configuration>
<rules>
<dependencyConvergence />
</rules>
</configuration>
```
with a rule that bans all commons-fileupload2 versions except the pinned one (`<includes>` are exceptions to the `<excludes>` bans):
```xml
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>org.apache.commons:commons-fileupload2-core</exclude>
<exclude>org.apache.commons:commons-fileupload2-jakarta-servlet6</exclude>
</excludes>
<includes>
<include>org.apache.commons:commons-fileupload2-core:${commons-fileupload2.version}</include>
<include>org.apache.commons:commons-fileupload2-jakarta-servlet6:${commons-fileupload2.version}</include>
</includes>
</bannedDependencies>
</rules>
</configuration>
```
- [ ] **Step 2: Bind the enforcer into the active `<plugins>` section**
In `pom.xml`, inside the active `<build><plugins>` block, add the enforcer plugin entry immediately after the `maven-release-plugin` entry (after line 378; version is inherited from `<pluginManagement>`):
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.3.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
</plugin>
```
- [ ] **Step 3: Verify the enforcer now executes and passes on the clean tree**
Run:
```bash
mvn -q validate -DskipAssembly
```
Expected: `BUILD SUCCESS`. To confirm the rule actually ran (not skipped), run:
```bash
mvn validate -DskipAssembly -pl core | grep -i "enforce"
```
Expected: a line showing `maven-enforcer-plugin:3.6.3:enforce (enforce)` executing.
- [ ] **Step 4: Verify the rule catches a skew (manual negative check, then revert)**
Temporarily edit `parent/pom.xml` to set the `commons-fileupload2-core` managed version to a different value (e.g. `2.0.0-M4` instead of `${commons-fileupload2.version}`), then run:
```bash
mvn validate -DskipAssembly -pl core
```
Expected: `BUILD FAILURE` with a `bannedDependencies` violation naming `commons-fileupload2-core`.
Then revert the edit:
```bash
git checkout -- parent/pom.xml
```
- [ ] **Step 5: Commit**
```bash
git add pom.xml
git commit -m "WW-5632 build: enforce a single commons-fileupload2 version
Activate maven-enforcer-plugin (previously dormant in pluginManagement)
with a fileupload-scoped bannedDependencies rule so any divergent
commons-fileupload2 version fails the build early.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Runtime API guard in AbstractMultiPartRequest (B)
**Files:**
- Test: `core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java` (create)
- Modify: `core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java` (imports ~line 22 & 34; new method block near `prepareServletFileUpload` at line 213; call site at line 214)
- [ ] **Step 1: Write the failing test**
Create `core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java`:
```java
/*
* 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.multipart;
import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload;
import org.apache.struts2.StrutsException;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class AbstractMultiPartRequestApiCheckTest {
@Test
public void verifyFileUploadApiPassesForCompatibleClass() {
assertThatCode(() -> AbstractMultiPartRequest.verifyFileUploadApi(JakartaServletDiskFileUpload.class))
.doesNotThrowAnyException();
}
@Test
public void verifyFileUploadApiThrowsForIncompatibleClass() {
assertThatThrownBy(() -> AbstractMultiPartRequest.verifyFileUploadApi(IncompatibleFileUpload.class))
.isInstanceOf(StrutsException.class)
.hasMessageContaining("setMaxSize")
.hasMessageContaining("Align commons-fileupload2-core");
}
/** Stub lacking the size-limit setters, simulating a binary-incompatible fileupload version. */
private static class IncompatibleFileUpload {
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
mvn test -DskipAssembly -pl core -Dtest=AbstractMultiPartRequestApiCheckTest
```
Expected: FAIL — compilation error `cannot find symbol: method verifyFileUploadApi(java.lang.Class)` (the guard does not exist yet). This is the red state.
- [ ] **Step 3: Add the two imports**
In `AbstractMultiPartRequest.java`, add the `-core` `AbstractFileUpload` import alongside the existing `fileupload2.core` imports (after line 22, `import org.apache.commons.fileupload2.core.DiskFileItemFactory;` — keep alphabetical, so `AbstractFileUpload` goes *before* it):
```java
import org.apache.commons.fileupload2.core.AbstractFileUpload;
import org.apache.commons.fileupload2.core.DiskFileItemFactory;
```
And add the `StrutsException` import after the existing `StrutsConstants` import (line 34):
```java
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
```
- [ ] **Step 4: Implement the guard and wire it into `prepareServletFileUpload`**
In `AbstractMultiPartRequest.java`, add `ensureFileUploadApiVerified();` as the first statement of `prepareServletFileUpload` (currently line 213-214):
```java
protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset charset, Path saveDir) {
ensureFileUploadApiVerified();
JakartaServletDiskFileUpload servletFileUpload = createJakartaFileUpload(charset, saveDir);
```
Then add the following members. Place the field next to the other static members (e.g. directly after the `LOG` field at line 61), and the three methods directly after the `prepareServletFileUpload` method (after its closing brace at line 229):
Field (after line 61):
```java
/**
* Verified once per JVM: whether the commons-fileupload2 API on the classpath matches what
* Struts compiled against. Guards against a mismatched milestone resolving at runtime.
*/
private static volatile boolean fileUploadApiVerified;
```
Methods (after `prepareServletFileUpload`):
```java
/**
* Verifies once per JVM that the commons-fileupload2 API on the classpath matches what Struts
* compiled against, failing fast with an actionable message instead of a deep-stack
* {@link NoSuchMethodError} when a mismatched milestone is resolved.
*/
private void ensureFileUploadApiVerified() {
if (!fileUploadApiVerified) {
verifyFileUploadApi(JakartaServletDiskFileUpload.class);
fileUploadApiVerified = true;
}
}
/**
* Probes {@code uploadClass} for the size-limit setters Struts invokes in
* {@link #prepareServletFileUpload}. Package-private for testing.
*
* @param uploadClass the file upload class to verify
* @throws StrutsException if any required method is absent, indicating a binary-incompatible
* commons-fileupload2 version on the classpath
*/
static void verifyFileUploadApi(Class<?> uploadClass) {
for (String method : new String[]{"setMaxSize", "setMaxFileCount", "setMaxFileSize"}) {
try {
uploadClass.getMethod(method, long.class);
} catch (NoSuchMethodException e) {
throw new StrutsException(String.format(
"Incompatible Apache Commons FileUpload on the classpath: %s.%s(long) is missing. " +
"Detected commons-fileupload2-core version [%s] and commons-fileupload2-jakarta-servlet6 version [%s]. " +
"Align commons-fileupload2-core with commons-fileupload2-jakarta-servlet6 (use the same release for both).",
uploadClass.getName(), method,
implementationVersion(AbstractFileUpload.class),
implementationVersion(uploadClass)), e);
}
}
}
private static String implementationVersion(Class<?> clazz) {
Package pkg = clazz.getPackage();
String version = pkg != null ? pkg.getImplementationVersion() : null;
return version != null ? version : "unknown";
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run:
```bash
mvn test -DskipAssembly -pl core -Dtest=AbstractMultiPartRequestApiCheckTest
```
Expected: PASS — both tests green.
- [ ] **Step 6: Run the multipart regression tests**
Run:
```bash
mvn test -DskipAssembly -pl core -Dtest='*MultiPartRequest*'
```
Expected: PASS — `JakartaMultiPartRequestTest` and `JakartaStreamMultiPartRequestTest` still green (the guard runs once and does not change upload behavior).
- [ ] **Step 7: Commit**
```bash
git add core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java \
core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java
git commit -m "WW-5632 fix(fileupload): fail fast on incompatible commons-fileupload2 API
Verify once per JVM that the fileupload size-limit setters exist and
throw a clear StrutsException reporting the core/jakarta version skew,
replacing an opaque deep-stack NoSuchMethodError in downstream runtimes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Final Verification
- [ ] **Run the full core test suite**
Run:
```bash
mvn test -DskipAssembly -pl core
```
Expected: `BUILD SUCCESS`, all tests pass, enforcer rule executed during `validate`.
- [ ] **Confirm the working tree is clean and the branch holds three commits**
Run:
```bash
git status --short && git log --oneline -3
```
Expected: no uncommitted changes; the three WW-5632 commits on top of the spec commit.
@@ -1,253 +0,0 @@
# FreeMarker Whitespace Stripping / devMode Decoupling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make FreeMarker whitespace stripping governed solely by `struts.freemarker.whitespaceStripping` (default `true`), removing the `devMode` auto-disable that broke `s:textarea` rendering and bloated HTML output in development.
**Architecture:** Remove the `&& !devMode` term in `FreemarkerManager.buildConfiguration()` and delete the now-unused `devMode` field/setter/injection. Update the Javadoc and the unit tests accordingly. `TextareaTest` serves as the rendering-level regression guard.
**Tech Stack:** Java, FreeMarker 2.3.34, JUnit 3-style tests (`testXxx` methods extending `StrutsInternalTestCase`), Maven.
**Spec:** `docs/superpowers/specs/2026-06-15-WW-5256-freemarker-whitespace-devmode-decoupling-design.md`
**Ticket:** [WW-5256](https://issues.apache.org/jira/browse/WW-5256)
---
### Task 1: Prove the bug with a failing test (RED)
This task documents the current broken behavior: with `devMode=true` and whitespace stripping
configured `true`, the manager wrongly reports stripping as disabled. This test is temporary —
it uses the `setDevMode` API that Task 2 removes, so Task 2 deletes it.
**Files:**
- Test: `core/src/test/java/org/apache/struts2/views/freemarker/FreemarkerManagerTest.java`
- [ ] **Step 1: Add the temporary failing test**
Insert this method after `testWhitespaceStrippingEnabledWhenNotInDevMode` (currently ends at line 169, before the closing `}` of the class):
```java
// TEMP (WW-5256): documents the pre-fix bug; removed in the same change that removes the devMode coupling.
public void testWhitespaceStrippingNotDisabledInDevMode() throws Exception {
// given
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("true");
manager.setDevMode("true");
// when
manager.init(servletContext);
// then
assertTrue(manager.config.getWhitespaceStripping());
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `mvn test -DskipAssembly -pl core -Dtest=FreemarkerManagerTest#testWhitespaceStrippingNotDisabledInDevMode`
Expected: FAIL — assertion error, `getWhitespaceStripping()` returns `false` because the current code computes `whitespaceStripping && !devMode`.
- [ ] **Step 3: Commit the failing test**
```bash
git add core/src/test/java/org/apache/struts2/views/freemarker/FreemarkerManagerTest.java
git commit -m "WW-5256 test: prove whitespace stripping wrongly disabled in devMode"
```
---
### Task 2: Decouple stripping from devMode (GREEN)
Apply the production fix and align the test suite. Because the `setDevMode` setter is removed,
the temporary test from Task 1 and the two original devMode-coupling tests are deleted, and the
config test drops its `setDevMode` call.
**Files:**
- Modify: `core/src/main/java/org/apache/struts2/views/freemarker/FreemarkerManager.java` (field at line 180; setter at 216-219; coupling at 355-357)
- Modify: `core/src/test/java/org/apache/struts2/views/freemarker/FreemarkerManagerTest.java` (lines 129-169)
- [ ] **Step 1: Replace the coupling in `FreemarkerManager`**
Find (lines 355-357):
```java
boolean enableWhitespaceStripping = whitespaceStripping && !devMode;
LOG.debug("Whitespace stripping: {} (configured: {}, devMode: {})", enableWhitespaceStripping, whitespaceStripping, devMode);
configuration.setWhitespaceStripping(enableWhitespaceStripping);
```
Replace with:
```java
LOG.debug("Whitespace stripping: {}", whitespaceStripping);
configuration.setWhitespaceStripping(whitespaceStripping);
```
- [ ] **Step 2: Remove the unused `devMode` field**
Find (line 180):
```java
protected boolean devMode;
```
Delete this line.
- [ ] **Step 3: Remove the unused `setDevMode` setter and its injection**
Find (lines 216-219):
```java
@Inject(value = StrutsConstants.STRUTS_DEVMODE, required = false)
public void setDevMode(String devMode) {
this.devMode = BooleanUtils.toBoolean(devMode);
}
```
Delete this block (the setter plus the blank line preceding it).
- [ ] **Step 4: Delete the temporary test from Task 1 and the two devMode-coupling tests**
In `FreemarkerManagerTest.java`, delete these three methods entirely:
- `testWhitespaceStrippingNotDisabledInDevMode` (added in Task 1)
- `testWhitespaceStrippingDisabledInDevMode` (lines 143-155)
- `testWhitespaceStrippingEnabledWhenNotInDevMode` (lines 157-169)
- [ ] **Step 5: Drop the `setDevMode` call from the config test**
Find (lines 129-141):
```java
public void testWhitespaceStrippingDisabledViaConfiguration() throws Exception {
// given
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("false");
manager.setDevMode("false");
// when
manager.init(servletContext);
// then
assertFalse(manager.config.getWhitespaceStripping());
}
```
Replace with (remove the `manager.setDevMode("false");` line):
```java
public void testWhitespaceStrippingDisabledViaConfiguration() throws Exception {
// given
FreemarkerManager manager = new FreemarkerManager();
container.inject(manager);
manager.setWhitespaceStripping("false");
// when
manager.init(servletContext);
// then
assertFalse(manager.config.getWhitespaceStripping());
}
```
- [ ] **Step 6: Verify no remaining references to the removed API**
Run: `grep -rn "setDevMode\|\.devMode" core/src/main/java/org/apache/struts2/views/freemarker/ core/src/test/java/org/apache/struts2/views/freemarker/`
Expected: no matches.
- [ ] **Step 7: Run the FreemarkerManager tests**
Run: `mvn test -DskipAssembly -pl core -Dtest=FreemarkerManagerTest`
Expected: PASS — `testWhitespaceStrippingEnabledByDefault` and `testWhitespaceStrippingDisabledViaConfiguration` both green; class compiles with no reference to `devMode`/`setDevMode`.
- [ ] **Step 8: Commit**
```bash
git add core/src/main/java/org/apache/struts2/views/freemarker/FreemarkerManager.java core/src/test/java/org/apache/struts2/views/freemarker/FreemarkerManagerTest.java
git commit -m "WW-5256 fix(freemarker): honor whitespaceStripping regardless of devMode"
```
---
### Task 3: Update the configuration Javadoc
**Files:**
- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (lines 335-342)
- [ ] **Step 1: Remove the stale devMode sentence**
Find (lines 335-342):
```java
/**
* Controls FreeMarker whitespace stripping during template compilation.
* When enabled (default), removes indentation and trailing whitespace from lines containing only FTL tags.
* Automatically disabled when devMode is enabled.
*
* @since 7.2.0
*/
public static final String STRUTS_FREEMARKER_WHITESPACE_STRIPPING = "struts.freemarker.whitespaceStripping";
```
Replace with:
```java
/**
* Controls FreeMarker whitespace stripping during template compilation.
* When enabled (default), removes indentation and trailing whitespace from lines containing only FTL tags.
*
* @since 7.2.0
*/
public static final String STRUTS_FREEMARKER_WHITESPACE_STRIPPING = "struts.freemarker.whitespaceStripping";
```
- [ ] **Step 2: Commit**
```bash
git add core/src/main/java/org/apache/struts2/StrutsConstants.java
git commit -m "WW-5256 docs: drop devMode note from whitespaceStripping constant"
```
---
### Task 4: Confirm rendering regression guard and full module build
`TextareaTest` renders the `s:textarea` tag against expected output fixtures. With stripping now
unconditionally on by default, it must stay green — this is the user-visible guarantee that the
textarea no longer emits blank lines.
**Files:**
- Verify only: `core/src/test/java/org/apache/struts2/views/jsp/ui/TextareaTest.java`
- [ ] **Step 1: Run the textarea rendering tests**
Run: `mvn test -DskipAssembly -pl core -Dtest=TextareaTest`
Expected: PASS — rendered output matches `Textarea-1.txt` / `Textarea-2.txt` fixtures (no internal blank lines).
- [ ] **Step 2: Run the full core test module**
Run: `mvn test -DskipAssembly -pl core`
Expected: BUILD SUCCESS — no compilation errors from the removed `devMode` API, all tests pass.
- [ ] **Step 3: No commit needed**
This task is verification only; no source changes.
---
## Self-Review
**Spec coverage:**
- Decouple stripping from devMode → Task 2 Step 1. ✓
- Remove `devMode` field/setter/`@Inject` → Task 2 Steps 2-3. ✓
- `default.properties` unchanged → no task needed (intentional). ✓
- Javadoc cleanup → Task 3. ✓
- Test updates (remove 2 devMode tests, drop stray `setDevMode` call, keep default/explicit tests) → Task 2 Steps 4-5. ✓
- Verification (`FreemarkerManagerTest`, textarea rendering, no internal blank lines) → Task 2 Step 7, Task 4. ✓
**Placeholder scan:** No TBD/TODO; every code step shows exact before/after content. The one `TEMP` marker is an intentional, scoped throwaway test removed within the same plan (Task 2 Step 4). ✓
**Type/signature consistency:** `whitespaceStripping` (existing `protected boolean`, default `true`) and `config.getWhitespaceStripping()` used consistently across tasks; `setDevMode`/`devMode` only referenced in the temporary Task 1 test and the deletions in Task 2. ✓
@@ -0,0 +1,263 @@
# OGNL 3.5.x Upgrade — Design Spec
> **Jira:** [WW-5326](https://issues.apache.org/jira/browse/WW-5326)
## Goal
Upgrade Apache Struts from OGNL 3.4.10 to OGNL 3.5.0-BETA4+ and introduce `StrutsContext extends OgnlContext<StrutsContext>` as the framework's own OGNL evaluation context. This lays the foundation for treating OGNL as an execution sandbox with typed, Struts-specific context state.
## Motivation
- **Forward-looking maintenance**: stay current with OGNL development, avoid a larger migration later
- **Real-world validation**: Struts is the primary consumer of OGNL — upgrading validates the 3.5.x generic API
- **Java 17 baseline**: OGNL 3.5.x requires Java 17, aligning with Struts 7.x
- **Type safety**: self-bounded generics (`OgnlContext<C>`) enable Struts to have a properly typed context instead of stringly-typed map entries
- **Sandbox foundation**: `StrutsContext` is the first step toward isolated OGNL evaluation contexts (`OgnlRuntime` instance-based isolation is future work)
## Non-Goals
- Instance-based `OgnlRuntime` / true sandbox isolation (future OGNL work)
- Consuming new OGNL features (null-safe operator `?.`, dual-mode evaluation) — those come as separate follow-ups
- Behavioral changes to security model, accessor logic, or expression evaluation
## Current State
### OGNL Usage in Struts
- **Version**: 3.4.10 (defined in root `pom.xml` as `ognl.version`)
- **Core dependency**: `core/pom.xml` depends on `ognl:ognl`
- **Context creation**: 3 call sites use `Ognl.createDefaultContext()`:
- `OgnlUtil.createDefaultContext()` (line 738)
- `OgnlValueStack.setRoot()` (line 124)
- `OgnlReflectionContextFactory.createDefaultContext()` (line 33)
- **No custom OgnlContext subclass**: Struts uses `OgnlContext` directly
- **Context state via map entries**: flags like `DENY_METHOD_EXECUTION`, `CREATE_NULL_OBJECTS`, `VALUE_STACK`, conversion state — all stored as stringly-typed map entries in `OgnlContext` and accessed via `ReflectionContextState` static methods
### OGNL Interface Implementations in Struts
| Interface | Struts Implementation |
|---|---|
| `MemberAccess` | `SecurityMemberAccess` |
| `TypeConverter` | `OgnlTypeConverterWrapper` |
| `ClassResolver` | `RootAccessor` (interface), `CompoundRootAccessor` (impl) |
| `PropertyAccessor` | `RootAccessor`, `CompoundRootAccessor`, `ObjectProxyPropertyAccessor`, + 8 classes extending `ObjectPropertyAccessor`/`ListPropertyAccessor`/`MapPropertyAccessor`/etc. |
| `MethodAccessor` | `RootAccessor`, `CompoundRootAccessor`, `XWorkMethodAccessor` |
| `NullHandler` | `OgnlNullHandlerWrapper` |
### Tiles Plugin OGNL Usage
6-8 files in `plugins/tiles` use OGNL directly:
- `ScopePropertyAccessor`, `AnyScopePropertyAccessor`, `NestedObjectDelegatePropertyAccessor`, `DelegatePropertyAccessor`
- `OGNLAttributeEvaluator`, `PropertyAccessorDelegateFactory`, `TilesContextPropertyAccessorDelegateFactory`
- Associated test files
## OGNL 3.5.x Key API Changes
### Self-Bounded Generics
All core interfaces and classes are now generic with `<C extends OgnlContext<C>>`:
```java
public class OgnlContext<C extends OgnlContext<C>> implements Map<String, Object>
public interface MemberAccess<C extends OgnlContext<C>>
public interface ClassResolver<C extends OgnlContext<C>>
public interface TypeConverter<C extends OgnlContext<C>>
public interface PropertyAccessor<C extends OgnlContext<C>>
public interface MethodAccessor<C extends OgnlContext<C>>
public interface NullHandler<C extends OgnlContext<C>>
public class ObjectPropertyAccessor<C extends OgnlContext<C>> implements PropertyAccessor<C>
// ... all base accessor classes similarly parameterized
```
### OgnlContext Constructor Changes
```java
// New (memberAccess first, required non-null)
public OgnlContext(MemberAccess<C> memberAccess, ClassResolver<C> classResolver, TypeConverter<C> typeConverter)
// Deprecated (old parameter order)
@Deprecated(forRemoval = true)
OgnlContext(ClassResolver<C> classResolver, TypeConverter<C> typeConverter, MemberAccess<C> memberAccess)
```
### OgnlContext.Builder
```java
public static class Builder<C extends OgnlContext<C>> {
public Builder(Function<Builder<C>, C> provider)
public Builder<C> withMemberAccess(MemberAccess<C> memberAccess)
public Builder<C> withClassResolver(ClassResolver<C> classResolver)
public Builder<C> withTypeConverter(TypeConverter<C> converter)
public Builder<C> withRoot(Object value)
public C build()
}
```
### Other Changes
- `SecurityManager` support removed
- Null-safe navigation operator (`?.`) added
- `setRoot()` deprecated in favor of `withRoot()` (fluent)
- Java 17 baseline
### Unchanged
- `Ognl` class remains abstract with only static methods (no instance-based evaluation)
- `OgnlRuntime` remains a static utility (global accessor/cache registration)
## Design
### Approach: Direct StrutsContext Construction
Struts creates `StrutsContext` directly, bypassing `Ognl.createDefaultContext()`. This gives Struts full ownership of context lifecycle and avoids the global-state issues of `Ognl.withBuilderProvider()`.
### StrutsContext
```java
package org.apache.struts2.ognl;
public class StrutsContext extends OgnlContext<StrutsContext> {
// Phase 1: just the constructor, delegate to super
public StrutsContext(SecurityMemberAccess memberAccess,
RootAccessor resolver,
OgnlTypeConverterWrapper converter) {
super(memberAccess, resolver, converter);
}
// Phase 2 (incremental): promote map entries to typed fields
// private ValueStack valueStack;
// private boolean reportErrorsOnNoProperty;
// private boolean throwExceptionOnFailure;
// private boolean createNullObjects;
// private boolean denyMethodExecution;
// private boolean denyIndexedAccessExecution;
// private String conversionPropertyFullName;
// private String currentPropertyPath;
// private Class<?> lastBeanClassAccessed;
// private String lastBeanPropertyAccessed;
}
```
Phase 1 introduces the class with zero behavioral change — it's just an `OgnlContext` subclass. The typed fields are a follow-up.
### Generic Type Ripple
All OGNL interface implementations parameterize with `<StrutsContext>`:
```java
// Core interfaces
public class SecurityMemberAccess implements MemberAccess<StrutsContext>
public class OgnlTypeConverterWrapper implements ognl.TypeConverter<StrutsContext>
public interface RootAccessor extends PropertyAccessor<StrutsContext>, MethodAccessor<StrutsContext>, ClassResolver<StrutsContext>
public class OgnlNullHandlerWrapper implements ognl.NullHandler<StrutsContext>
// Accessors (extend generic base classes)
public class CompoundRootAccessor implements RootAccessor
public class ObjectProxyPropertyAccessor implements PropertyAccessor<StrutsContext>
public class ObjectAccessor extends ObjectPropertyAccessor<StrutsContext>
public class ParameterPropertyAccessor extends ObjectPropertyAccessor<StrutsContext>
public class HttpParametersPropertyAccessor extends ObjectPropertyAccessor<StrutsContext>
public class XWorkObjectPropertyAccessor extends ObjectPropertyAccessor<StrutsContext>
public class XWorkEnumerationAccessor extends EnumerationPropertyAccessor<StrutsContext> // verify base class
public class XWorkIteratorPropertyAccessor extends IteratorPropertyAccessor<StrutsContext> // verify base class
public class XWorkCollectionPropertyAccessor extends ObjectPropertyAccessor<StrutsContext>
public class XWorkListPropertyAccessor extends ListPropertyAccessor<StrutsContext>
public class XWorkMapPropertyAccessor extends MapPropertyAccessor<StrutsContext>
public class XWorkMethodAccessor extends ObjectMethodAccessor<StrutsContext>
```
Method signatures change `OgnlContext` parameters to `StrutsContext` throughout.
### Context Creation
Replace `Ognl.createDefaultContext()` with direct construction:
```java
// OgnlUtil.createDefaultContext()
protected StrutsContext createDefaultContext(Object root, ClassResolver<StrutsContext> resolver) {
if (resolver == null) {
resolver = container.getInstance(RootAccessor.class);
}
StrutsContext ctx = new StrutsContext(
container.getInstance(SecurityMemberAccess.class), resolver, defaultConverter);
ctx.withRoot(root);
return ctx;
}
// OgnlValueStack.setRoot()
StrutsContext ognlContext = new StrutsContext(securityMemberAccess, accessor,
new OgnlTypeConverterWrapper(xworkConverter));
ognlContext.withRoot(this.root);
// OgnlReflectionContextFactory — already @Deprecated(forRemoval=true) since 6.8.0
// Keep using Ognl.createDefaultContext(root) with raw type, or remove entirely
```
### Tiles Plugin
The tiles plugin accessors operate on tiles-specific objects, not on `StrutsContext` directly. Options:
- Parameterize with raw `OgnlContext` (use `PropertyAccessor<OgnlContext>`) if OGNL allows it
- Use wildcard `PropertyAccessor<?>` if supported
- Parameterize with `StrutsContext` if tiles always runs within a Struts context
Decision: determine during implementation based on what compiles cleanly.
### XWorkTypeConverterWrapper
Currently casts `Map` context to `OgnlContext`. After upgrade, `ognl.TypeConverter<StrutsContext>` passes `StrutsContext` directly — the cast goes away. Struts' own `TypeConverter` interface (in `conversion` package) may also need its `convertValue` signature updated.
### ReflectionContextState
Initially unchanged — continues to work via `Map<String, Object>` interface that `StrutsContext` inherits from `OgnlContext`. Promoting to typed fields is a follow-up.
## Implementation Phases
### Phase 1: Version bump + StrutsContext + generics (this effort)
1. Bump `ognl.version` to `3.5.0-BETA4` in root `pom.xml`
2. Create `StrutsContext extends OgnlContext<StrutsContext>` (constructor only)
3. Update all OGNL interface implementations with `<StrutsContext>` type parameter (~20 classes in core)
4. Update method signatures: `OgnlContext``StrutsContext` in all accessor/handler implementations
5. Replace `Ognl.createDefaultContext()` with direct `StrutsContext` construction (3 call sites)
6. Update tiles plugin accessor classes (~6-8 files)
7. Update test files (~50+ files referencing `OgnlContext`)
8. Verify all tests pass
### Phase 2: Typed context fields (follow-up)
- Promote `ReflectionContextState` map entries to `StrutsContext` typed fields
- Update accessors to use typed getters instead of `context.get("string.key")`
- Deprecate `ReflectionContextState` static methods
### Phase 3: Sandbox features (future, requires OGNL changes)
- Instance-based `OgnlRuntime` (OGNL-side work)
- Per-sandbox accessor registrations
- Isolated evaluation engines
## Risk Areas
### OgnlRuntime global statics
`OgnlRuntime.setPropertyAccessor(Class<?>, PropertyAccessor<C>)` is generic but the registration is global. Registering `PropertyAccessor<StrutsContext>` may cause unchecked warnings or issues when OGNL internally retrieves accessors with a different context type. May need raw types at registration boundary.
### OGNL internal context preservation
If OGNL internally creates new `OgnlContext` instances during expression evaluation (rather than preserving the passed-in `StrutsContext`), typed fields would be lost. BETA1 addressed "context root preservation during nested evaluations" but this needs runtime verification.
### Tiles plugin type compatibility
Tiles accessors may not naturally fit `StrutsContext` parameterization. Need to determine the right generic type during implementation.
### OGNL BETA stability
OGNL 3.5.0 is still in BETA. API changes may occur in subsequent releases. This is acceptable given the user is an OGNL contributor and can influence the API.
## Expected Outcomes
- Struts compiles and all tests pass against OGNL 3.5.0-BETA4
- `StrutsContext` exists as the framework's OGNL context class
- All OGNL interface implementations are properly parameterized with `<StrutsContext>`
- Foundation is in place for typed context fields and eventual sandbox isolation
- Any OGNL API issues discovered are reported/fixed upstream
@@ -1,206 +0,0 @@
# Design: Harden commons-fileupload2 against milestone churn
**Date:** 2026-06-10
**Status:** Approved design — pending implementation plan
**Ticket:** [WW-5632](https://issues.apache.org/jira/browse/WW-5632)
**Origin:** [user@struts mailing list thread](https://lists.apache.org/thread/fcdls8xvd9tp9o6dcog65vkqozv4nq5x)
(Tamás Barta, Struts 7.1.1 file upload `NoSuchMethodError`)
**Related (closed):** [WW-5615](https://issues.apache.org/jira/browse/WW-5615) — "Adapt to renamed
methods in Apache Commons FileUpload 2.0.0-M5", fixed in 7.2.0 via PR #1584 / commit `d2810d42f`.
## Context
A user on Struts 7.1.1 reported `java.lang.NoSuchMethodError:
'void org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload.setSizeMax(long)'`
at upload time. Apache Commons FileUpload 2.0.0-M5 renamed several `AbstractFileUpload` methods
(`setSizeMax``setMaxSize`, `setFileSizeMax``setMaxFileSize`, `setFileCountMax``setMaxFileCount`),
breaking binary compatibility with M4. Struts declared M4 but the user's build resolved M5.
WW-5615 (PR #1584) fixed the **symptom** for 7.2.0: it renamed the three call sites in
`AbstractMultiPartRequest.java` and bumped `commons-fileupload2-jakarta-servlet6` M4 → M5 in
`parent/pom.xml`. That commit did **nothing else**.
This design addresses the **class of failure** that WW-5615 left open.
## Root-cause chain (verified on current `main`, 7.2.0-SNAPSHOT)
1. **Milestone dependency.** Struts depends on `-M` builds of commons-fileupload2, which break
binary compatibility between milestones. Until a 2.0.0 GA exists, Struts is committed to
milestone artifacts.
2. **The volatile API lives in an unmanaged artifact.** `setMaxSize(long)` / `setMaxFileCount(long)`
/ `setMaxFileSize(long)` are declared on `org.apache.commons.fileupload2.core.AbstractFileUpload`
in **`commons-fileupload2-core`** (verified via `javap`). `JakartaServletDiskFileUpload` merely
inherits them. `parent/pom.xml` `<dependencyManagement>` pins only
`commons-fileupload2-jakarta-servlet6`**`commons-fileupload2-core` is unmanaged.** A transitive
dependency pulling a different `-core` milestone reproduces the exact `NoSuchMethodError` even
when `-jakarta-servlet6` is pinned correctly.
3. **The build guardrail is dormant.** `maven-enforcer-plugin` is configured with a
`dependencyConvergence` rule, but **only inside `<pluginManagement>`** of the root `pom.xml`; it is
never bound to an active `<plugins>` section, so it never executes. Struts's own build would not
catch a fileupload version skew.
4. **The BOM does not help consumers.** `struts2-bom` exports only Struts module versions, not the
fileupload version. Downstream apps importing the BOM get no convergence assistance.
Net effect: a downstream/transitive dependency can select a mismatched `commons-fileupload2-core`
milestone, and because milestones break binary compatibility, the user gets a runtime
`NoSuchMethodError` deep in request handling, with no build-time warning.
## Goals
- Make a `commons-fileupload2-core` / `-jakarta-servlet6` version skew **impossible within Struts's
own build**, deterministically.
- Fail the Struts build **early and clearly** if a future transitive dependency wants a
commons-fileupload2 version other than the tested one.
- For downstream consumer runtimes (where Struts's build guards cannot reach), replace the opaque
deep-stack `NoSuchMethodError` with a **clear, actionable `StrutsException`**.
## Non-goals
- **Shading/relocating commons-fileupload2.** Rejected: the library is security-sensitive (CVE
history); shading would force Struts to re-release on every fileupload CVE, against Apache norms,
and bloats the artifact.
- **Exporting the fileupload version through `struts2-bom`.** Considered and deferred — out of scope
for this change.
- **Migrating off milestone versions.** Not actionable until a commons-fileupload2 2.0.0 GA exists.
## Design
### Part A — Build-time fail-fast (POM)
**A1. Manage both artifacts at one version.**
Introduce a single `commons-fileupload2.version` property (single source of truth) and add a
`<dependencyManagement>` entry for `org.apache.commons:commons-fileupload2-core` alongside the
existing `commons-fileupload2-jakarta-servlet6` entry in `parent/pom.xml`, both referencing the
property. Because `<dependencyManagement>` wins Maven version mediation, this forces a single,
matched `-core` version across the entire Struts reactor regardless of transitive requests —
closing root-cause #2 deterministically for Struts's own build.
**A2. Activate a narrowly-scoped enforcer (chosen over global `dependencyConvergence`).**
Bind `maven-enforcer-plugin` into an active `<plugins>` section with a `bannedDependencies` rule
scoped **only** to commons-fileupload2: ban all versions of
`org.apache.commons:commons-fileupload2-core` and
`org.apache.commons:commons-fileupload2-jakarta-servlet6` **except** the pinned
`${commons-fileupload2.version}`. This fails the build immediately if any transitive dependency
introduces a different fileupload version, with effectively zero blast radius on unrelated
dependencies.
> **Why not global `dependencyConvergence`?** It has never actually run; activating it may surface
> many pre-existing, unrelated version conflicts across the multi-module build, ballooning scope
> unpredictably. The fileupload-scoped `bannedDependencies` rule targets exactly the failure mode in
> this report. (Global convergence remains a reasonable separate cleanup task, out of scope here.)
The pinned version string lives once in the `commons-fileupload2.version` property and is referenced
by both the `<dependencyManagement>` entries and the enforcer rule — no duplicated literals.
### Part B — Runtime diagnostics guard
Add a one-time, package-private static check in `AbstractMultiPartRequest`, invoked on first use
(e.g. at the top of `prepareServletFileUpload`), guarded so the reflective probe runs **once** per
JVM — no per-request cost.
**Probe (testable, pure):**
`static void verifyFileUploadApi(Class<?> uploadClass)` reflectively confirms that `uploadClass`
declares (inherited included) `setMaxSize(long)`, `setMaxFileCount(long)`, and `setMaxFileSize(long)`.
If any is absent it throws `org.apache.struts2.StrutsException`.
**Self-maintaining message (no hardcoded "expected" version):** the exception reports the
implementation versions read at runtime from both packages —
`org.apache.commons.fileupload2.core.AbstractFileUpload.class.getPackage().getImplementationVersion()`
(the `-core` version) and `JakartaServletDiskFileUpload.class.getPackage().getImplementationVersion()`
(the `-jakarta-servlet6` version) — names the missing method, and instructs the user to align
`commons-fileupload2-core` with `commons-fileupload2-jakarta-servlet6`. Versions fall back to
`"unknown"` when no manifest implementation version is present. Surfacing the **skew** (core vs
jakarta versions) is the actionable signal; no version constant is baked into Struts to drift.
**One-time guard:** the caller wraps `verifyFileUploadApi(JakartaServletDiskFileUpload.class)` with a
JVM-once gate (`static volatile boolean` or a holder). The probe method itself is stateless so tests
can call it repeatedly.
## Testing & verification
**Part A:**
- `mvn validate -DskipAssembly` runs the enforcer clean on the current tree (no fileupload skew
exists today).
- Manual negative check: temporarily declare a conflicting `commons-fileupload2-core` version and
confirm the build fails with the banned-dependency message; revert.
**Part B (unit tests in `AbstractMultiPartRequestTest`):**
- `verifyFileUploadApi(JakartaServletDiskFileUpload.class)` does **not** throw (real classpath has the
M5 API).
- `verifyFileUploadApi(<stub class lacking the setters>)` throws `StrutsException`; assert the message
names the missing method and the remediation (align `-core` with `-jakarta-servlet6`).
Full suite: `mvn test -DskipAssembly -pl core`.
## Risks
- **Enforcer noise (mitigated).** Scoping `bannedDependencies` to commons-fileupload2 only avoids the
unbounded scope risk of global `dependencyConvergence`.
- **Reflective probe drift.** If a future commons-fileupload2 release renames these setters again, the
probe's hardcoded method names become a deliberate tripwire to update alongside the dependency bump
— acceptable and intended.
- **Null implementation version.** Handled via `"unknown"` fallback so the guard never NPEs while
building its diagnostic message.
## Out of scope / follow-ups
- Tracked under [WW-5632](https://issues.apache.org/jira/browse/WW-5632).
- Global `dependencyConvergence` cleanup across the reactor.
- Exporting third-party versions through `struts2-bom`.
- Revisiting the dependency once commons-fileupload2 2.0.0 GA ships.
## JIRA ticket
**Summary (title):**
```
Harden commons-fileupload2 dependency against milestone binary-incompatibility
```
**Description (JIRA wiki markup — paste into the description field):**
```
h3. Background
[WW-5615|https://issues.apache.org/jira/browse/WW-5615] fixed the {{NoSuchMethodError}}
caused by Apache Commons FileUpload 2.0.0-M5 renaming {{setSizeMax}} -> {{setMaxSize}}
(and friends), shipped in 7.2.0 via [#1584|https://github.com/apache/struts/pull/1584].
That fix addressed the *symptom* for one milestone bump but not the underlying *class of
failure*.
h3. Problem
Struts depends on _milestone_ ({{-M}}) builds of commons-fileupload2, which break binary
compatibility between milestones. Three gaps remain on {{main}}:
* The renamed setters ({{setMaxSize}}, {{setMaxFileCount}}, {{setMaxFileSize}}) live in
*{{commons-fileupload2-core}}* ({{AbstractFileUpload}}), but only
{{commons-fileupload2-jakarta-servlet6}} is pinned in {{dependencyManagement}} —
{{-core}} is unmanaged, so a transitive dependency can pull a mismatched {{-core}}
milestone and reproduce the {{NoSuchMethodError}}.
* The {{maven-enforcer-plugin}} {{dependencyConvergence}} rule sits only in
{{<pluginManagement>}} and is never bound to an active {{<plugins>}} section, so it
never runs — the build cannot catch a fileupload version skew.
* Downstream consumer runtimes get an opaque, deep-stack {{NoSuchMethodError}} with no
actionable guidance.
h3. Proposed changes
* *(A1)* Introduce a single {{commons-fileupload2.version}} property and manage *both*
{{commons-fileupload2-core}} and {{commons-fileupload2-jakarta-servlet6}} at that version
in {{parent/pom.xml}}, forcing a matched {{-core}} version across the reactor.
* *(A2)* Activate {{maven-enforcer-plugin}} with a fileupload-scoped {{bannedDependencies}}
rule that fails the build on any commons-fileupload2 version other than the pinned one
(narrow scope, near-zero blast radius).
* *(B)* Add a once-per-JVM reflective guard in {{AbstractMultiPartRequest}} that throws a
clear {{StrutsException}} reporting the {{-core}} vs {{-jakarta-servlet6}} version skew
instead of an opaque {{NoSuchMethodError}}.
Full design: {{docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md}}
h3. Affects / Fix version
* Affects: 7.1.1+ (root cause present on 7.2.0-SNAPSHOT {{main}})
* Component: File Upload
```
@@ -1,125 +0,0 @@
---
date: 2026-06-15
ticket: WW-5256
url: https://issues.apache.org/jira/browse/WW-5256
status: design
---
# WW-5256 — Decouple FreeMarker whitespace stripping from devMode
## Problem
In `release/7.2.0-RC1`, `s:textarea` renders blank lines inside an empty textarea, and
the overall HTML output contains noticeably more whitespace than in 6.x. Reported symptom
on CRUD entry/add pages: an empty `bean.data` (null default) textarea shows up as two blank
lines on screen, and the raw HTML source has far more whitespace throughout than the 6.x
equivalent.
## Root cause
The FreeMarker templates are **not** the cause. `textarea.ftl` and all of its included
sub-templates (`css.ftl`, `scripting-events.ftl`, `common-attributes.ftl`,
`dynamic-attributes.ftl`) are byte-for-byte identical between `support/struts-6-x-x` and
`release/7.2.0-RC1` apart from the `parameters``attributes` rename. Their whitespace
structure is unchanged.
The regression is in `FreemarkerManager.java:355`, introduced by WW-5256
(commit `9305a5812`, `@since 7.2.0`):
```java
// 6.x — whitespace stripping unconditionally on:
configuration.setWhitespaceStripping(true);
// 7.2.0-RC1 — stripping turned off whenever devMode is on:
boolean enableWhitespaceStripping = whitespaceStripping && !devMode;
configuration.setWhitespaceStripping(enableWhitespaceStripping);
```
When `struts.devMode=true` (the normal development setting), whitespace stripping is
forced off. FreeMarker then stops collapsing directive-only lines (e.g. `<#if …>` /
`</#if>`), which produces two distinct effects:
1. **General whitespace bloat** — every directive-only line in every UI template now emits
its newline and indentation.
2. **Visible `s:textarea` breakage** — the directive lines around `nameValue` sit *inside*
`<textarea>…</textarea>`, where whitespace is **significant content**. The collapsed
newlines that 6.x removed now render as blank lines in the browser.
The `&& !devMode` term also **overrides** the `struts.freemarker.whitespaceStripping`
config flag: a developer running in devMode has no way to turn stripping back on.
### Why the coupling was introduced (and why it was wrong)
Per the WW-5256 research note
(`thoughts/shared/research/2025-09-24-WW-5256-freemarker-whitespace-compression.md`), the
devMode auto-disable was copied by analogy from the new `<s:compress>` tag, which
intentionally disables compression in devMode with a `force` override. The stated goal was
"readable output while debugging." The note itself raised this as an *open question*
("Should whitespace stripping be automatically disabled in DevMode … or require explicit
configuration?") rather than a settled decision.
The analogy does not hold: for most tags the extra dev whitespace is harmless cosmetic
noise, but for `<textarea>` (and `<pre>`) the stripped newlines are semantically
significant page content. "Make dev output readable" therefore silently broke `s:textarea`
rendering, with no escape hatch. The configurable flag from WW-5256 is the useful part and
is kept; the devMode auto-disable is the unvalidated part and is removed.
## Design
Decouple whitespace stripping from devMode. Honor `struts.freemarker.whitespaceStripping`
(default `true`) unconditionally. This restores 6.x behavior by default while keeping an
explicit opt-out for anyone who genuinely wants raw, un-collapsed template output.
### Changes
1. **`core/src/main/java/org/apache/struts2/views/freemarker/FreemarkerManager.java`**
- Replace the coupling with a direct call:
```java
configuration.setWhitespaceStripping(whitespaceStripping);
```
- Simplify the debug log to drop the devMode reference.
- Remove the now-unused `devMode` field, the `setDevMode(String)` setter, and its
`@Inject(value = StrutsConstants.STRUTS_DEVMODE, required = false)` annotation. The
field was added in 7.2.0 solely for this coupling and is referenced nowhere else in
the class, so removing it before final 7.2.0 release breaks no released API.
2. **`core/src/main/java/org/apache/struts2/StrutsConstants.java`**
- Remove the line "*Automatically disabled when devMode is enabled.*" from the
`STRUTS_FREEMARKER_WHITESPACE_STRIPPING` Javadoc. Keep `@since 7.2.0`.
3. **`core/src/main/resources/org/apache/struts2/default.properties`**
- No change. `struts.freemarker.whitespaceStripping=true` remains the default.
4. **`core/src/test/java/org/apache/struts2/views/freemarker/FreemarkerManagerTest.java`**
- Remove `testWhitespaceStrippingDisabledInDevMode` and
`testWhitespaceStrippingEnabledWhenNotInDevMode` — they assert the removed coupling and
call the removed `setDevMode` setter.
- Remove the stray `manager.setDevMode("false")` call from
`testWhitespaceStrippingDisabledViaConfiguration`.
- Keep `testWhitespaceStrippingEnabledByDefault` and the explicit-disable test; together
they fully cover the remaining behavior (default on; honored when set false).
### Behavior after the change
| devMode | `struts.freemarker.whitespaceStripping` | Stripping enabled? |
|---------|-----------------------------------------|--------------------|
| any | unset (default) | yes (matches 6.x) |
| any | `true` | yes |
| any | `false` | no |
## Scope and risk
- Contained, single-purpose fix landing in `release/7.2.0-RC1` before the final 7.2.0
release. No migration or backward-compatibility concern, since the removed `devMode`
wiring was never in a released version.
- **Not** a security change — this is purely rendering/whitespace behavior. The normal
PR flow applies (no private security-triage path needed).
- The `<s:compress>` tag's own devMode/`force` behavior is unrelated and unchanged.
## Verification
- `mvn test -DskipAssembly -pl core -Dtest=FreemarkerManagerTest` passes with the updated
test set.
- Manual check (or an `AbstractUITagTest`-style assertion): an `s:textarea` bound to a null
value renders as `<textarea …></textarea>` with no internal blank lines when
`struts.devMode=true`.
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-jakarta</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-jakarta</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-velocity-tools-jsp-jakarta</artifactId>
<packaging>jar</packaging>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-jakarta</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-velocity-tools-view-jakarta</artifactId>
<packaging>jar</packaging>
+5 -10
View File
@@ -25,7 +25,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-bom</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
<relativePath>../bom/pom.xml</relativePath>
</parent>
@@ -51,7 +51,7 @@
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.2.4</version>
<version>3.2.3</version>
</dependency>
<!-- Velocity -->
@@ -125,20 +125,15 @@
<artifactId>commons-collections4</artifactId>
<version>4.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-fileupload2-core</artifactId>
<version>${commons-fileupload2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
<version>${commons-fileupload2.version}</version>
<version>2.0.0-M5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.22.0</version>
<version>2.21.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
@@ -222,7 +217,7 @@
<dependency>
<groupId>org.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>5.1.0</version>
<version>4.21.0</version>
<scope>test</scope>
</dependency>
<dependency>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-async-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
+1 -1
View File
@@ -25,7 +25,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-cdi-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.1</version>
<version>7.2.0-SNAPSHOT</version>
</parent>
<artifactId>struts2-config-browser-plugin</artifactId>

Some files were not shown because too many files have changed in this diff Show More