mirror of
https://github.com/apache/struts.git
synced 2026-08-07 23:57:03 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b039dc5079 | |||
| 6374e31384 | |||
| 803b5cbbd0 | |||
| 1dda92ed23 | |||
| cda38c79db | |||
| fbb904e9c6 | |||
| f7923fc704 | |||
| 47718f5a8a | |||
| e2e8fa1f33 | |||
| 71c204f04b |
@@ -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. |
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -21,7 +21,6 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/*'
|
||||
- 'support/*'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+12
-61
@@ -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
|
||||
|
||||
+1
-2
@@ -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());
|
||||
|
||||
-192
@@ -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);
|
||||
}
|
||||
-144
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
-21
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-244
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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());
|
||||
|
||||
+11
-9
@@ -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> {
|
||||
}
|
||||
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
+3
-3
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
-47
@@ -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();
|
||||
|
||||
-219
@@ -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;
|
||||
}
|
||||
}
|
||||
-81
@@ -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);
|
||||
|
||||
-154
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-129
@@ -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();
|
||||
}
|
||||
}
|
||||
-275
@@ -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; }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
-16
@@ -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)\"¶m=<script>", mockInvocation);
|
||||
|
||||
String output = res.getContentAsString();
|
||||
|
||||
// The action attribute must contain escaped HTML entities
|
||||
assertTrue("Double quote should be escaped to "",
|
||||
output.contains("action=\"/test"onmouseover="alert(1)"&param=<script>\""));
|
||||
// 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("&"));
|
||||
assertTrue("Double-quote should be escaped", output.contains("""));
|
||||
assertTrue("Less-than should be escaped", output.contains("<"));
|
||||
assertTrue("Greater-than should be escaped", output.contains(">"));
|
||||
|
||||
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=<script>alert(1)</script>&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.
|
||||
-253
@@ -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
|
||||
```
|
||||
|
||||
-125
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user