Compare commits

...

48 Commits

Author SHA1 Message Date
Lukasz Lenart 8e908137d0 [maven-release-plugin] prepare release STRUTS_7_2_0 2026-06-14 19:33:03 +02:00
Arun cc3ebc3c1f WW-5635 Avoid logging sensitive token values in TokenHelper (#1738)
* Avoid logging sensitive token values in TokenHelper

Redact form and session token values from WARN-level log output
in TokenHelper.validToken() and update corresponding i18n message
properties. Detailed diagnostics moved to DEBUG level with
sanitized input.

* Update struts-messages.properties

* Update invalid token error message for clarity

* Update struts-messages_da.properties

* Update struts-messages_de.properties

* Update Polish translation for invalid token message

* Update invalid token message in Portuguese properties

* Improve token mismatch warning logging

Updated warning message to include the form token in the log.

* Update struts-messages.properties

* Update invalid token message format in properties file

* Update invalid token message for clarity

* Update struts-messages_de.properties

* Update struts-messages_pl.properties

* Update invalid token message format in properties file

* Update TokenHelper.java

* Refactor token mismatch logging for development mode
2026-06-14 17:08:39 +00:00
Arun 8f9b4b8a90 WW-5636 Harden redirect URL escaping in non-302 response body (#1737)
* Implement test for status code 200 with HTML escaping

* Escape HTML in ServletRedirectResult response

Escape HTML in the final location before writing to the response.
2026-06-14 18:44:43 +02:00
Lukasz Lenart dd830dca80 WW-5630 test: streamline ConfigParseUtilTest and convert to JUnit 4 (#1740)
Collapse 12 overlapping cache tests to 5 focused ones, replace the
~80-entry JDK class-name literal with a synthetic-name loop bounded by
the inner-cache limit, and drop reflection from the behavioral tests
(load-count assertions only). Reflection is retained solely in the two
size-bound tests, where Caffeine exposes no public seam.

Production ConfigParseUtil caching logic is unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:16:39 +00:00
dependabot[bot] 289bcaa922 build(deps): bump org.htmlunit:htmlunit from 4.21.0 to 5.1.0 (#1733)
Bumps [org.htmlunit:htmlunit](https://github.com/HtmlUnit/htmlunit) from 4.21.0 to 5.1.0.
- [Release notes](https://github.com/HtmlUnit/htmlunit/releases)
- [Commits](https://github.com/HtmlUnit/htmlunit/compare/4.21.0...5.1.0)

---
updated-dependencies:
- dependency-name: org.htmlunit:htmlunit
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 08:34:36 +02:00
dependabot[bot] b808dcb845 build(deps): bump com.fasterxml.jackson:jackson-bom (#1729)
Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.21.3 to 2.22.0.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.21.3...jackson-bom-2.22.0)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson:jackson-bom
  dependency-version: 2.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 08:33:28 +02:00
Lukasz Lenart 4f3fd69aa6 WW-5632 Harden commons-fileupload2 dependency against milestone binary-incompatibility (#1735)
* WW-5632 docs: add commons-fileupload2 milestone-hardening design spec

Design for hardening the commons-fileupload2 dependency against
milestone binary-incompatibility (manage -core, activate a scoped
enforcer rule, add a runtime API guard in AbstractMultiPartRequest).

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

* WW-5632 docs: add implementation plan for fileupload2 milestone hardening

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

* 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>

* 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>

* 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>

* WW-5632 fix(fileupload): make API-verification guard static

Resolve Sonar java:S2696 (instance method writing a static field) by
making ensureFileUploadApiVerified() static; verification is JVM-global.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:28:47 +02:00
brianandle 210dc86b88 WW-5630 - Performance Issue SecurityMemberAccess (#1721)
* WW-5630 - Performance Issue SecurityMemberAccess
* Add size bound cache, 50, for Class lookup
* Add unit test

Code generated by Copilot

* WW-5630 - Add additional UT

* WW-5630 - Add UT for non-existent class

* WW-5630 - Review feedback changes
* Cache ClassLoader directly
* Use weakKeys and weakValues
* Comment on the ClassLookupException
* Additional Unit Tests

Assistance in coding using co-pilot

* WW-5630 - Additional review
* Limit outer, Classloader, to 25. Ensure memory bounding.
* Limit inner, Classes, to 50. Ensure memory bounding.
* Additional UTs

With co-pilot assitance
2026-06-12 17:12:26 +00:00
dependabot[bot] 7c6034c667 build(deps-dev): bump org.apache.maven.plugins:maven-failsafe-plugin (#1732)
Bumps [org.apache.maven.plugins:maven-failsafe-plugin](https://github.com/apache/maven-surefire) from 3.5.5 to 3.5.6.
- [Release notes](https://github.com/apache/maven-surefire/releases)
- [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.5...surefire-3.5.6)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-failsafe-plugin
  dependency-version: 3.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 13:23:50 +02:00
dependabot[bot] 5e725a72cd build(deps): bump github/codeql-action from 4.36.0 to 4.36.2 (#1734)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.36.0...v4.36.2)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 13:23:21 +02:00
dependabot[bot] 53ef1ad883 build(deps): bump org.apache.maven.plugins:maven-dependency-plugin (#1730)
Bumps [org.apache.maven.plugins:maven-dependency-plugin](https://github.com/apache/maven-dependency-plugin) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/apache/maven-dependency-plugin/releases)
- [Commits](https://github.com/apache/maven-dependency-plugin/compare/maven-dependency-plugin-3.10.0...maven-dependency-plugin-3.11.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-dependency-plugin
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 13:22:50 +02:00
dependabot[bot] 0972a608a3 build(deps): bump maven-surefire-plugin.version from 3.5.5 to 3.5.6 (#1731)
Bumps `maven-surefire-plugin.version` from 3.5.5 to 3.5.6.

Updates `org.apache.maven.surefire:surefire-junit47` from 3.5.5 to 3.5.6

Updates `org.apache.maven.plugins:maven-surefire-plugin` from 3.5.5 to 3.5.6
- [Release notes](https://github.com/apache/maven-surefire/releases)
- [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.5...surefire-3.5.6)

---
updated-dependencies:
- dependency-name: org.apache.maven.surefire:surefire-junit47
  dependency-version: 3.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.apache.maven.plugins:maven-surefire-plugin
  dependency-version: 3.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 13:22:32 +02:00
Lukasz Lenart 9011c32b38 WW-5631 Add opt-in @StrutsParameter enforcement to ChainingInterceptor (#1719)
* WW-5631 feat(chaining): add struts.chaining.requireAnnotations constant

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

* WW-5631 feat(chaining): default struts.chaining.requireAnnotations=false

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

* WW-5631 test(chaining): add annotated/unannotated chaining fixtures

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

* WW-5631 test(chaining): add failing @StrutsParameter enforcement tests

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

* WW-5631 feat(chaining): enforce @StrutsParameter on target when opted in

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

* WW-5631 refactor(chaining): align requireAnnotations parsing with BooleanUtils

Use BooleanUtils.toBoolean for the chaining requireAnnotations flag so it
accepts the same values (yes/on/1) as the sibling
struts.parameters.requireAnnotations switch, and unify the enforcement WARN
message prefix.

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

* WW-5631 test(chaining): cover includes interaction and proxied target

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

* WW-5631 docs(chaining): document struts.chaining.requireAnnotations

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

* WW-5631 test(chaining): cover fail-closed introspection; clarify target==action

Add a test asserting nothing is copied when the target action cannot be
introspected (fail-closed), and document why isAuthorized is called with
target == action for chaining (no ModelDriven exemption).

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

* WW-5631 fix(chaining): address SonarCloud findings

- Mark injected parameterAuthorizer/ognlUtil fields transient (S1948);
  they are re-injected by the container, not serialized.
- Extract per-object copy into copyObjectToAction so the copyStack loop
  uses no break/continue (S135); fail-closed path now returns from the
  helper instead of continuing the loop.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 13:19:43 +02:00
Lukasz Lenart 69cb2964f0 docs(skills): add triaging-security-reports skill (#1722)
Add an agent skill for triaging privately-disclosed security reports:
research each claim from source without trusting the reporter, verify
effective runtime defaults (config overrides field initializers), avoid
introducing unverified facts into responses, and frame findings as
vulnerability vs. operator responsibility.

Developed test-first: a baseline run produced contradictory, unverified
claims about defaults; the skill closes that gap and was verified to also
avoid over-correcting into reflexive rejection of valid reports.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:44:18 +02:00
dependabot[bot] e637bb16a3 build(deps): bump asm.version from 9.10 to 9.10.1 (#1713)
Bumps `asm.version` from 9.10 to 9.10.1.

Updates `org.ow2.asm:asm` from 9.10 to 9.10.1

Updates `org.ow2.asm:asm-commons` from 9.10 to 9.10.1

---
updated-dependencies:
- dependency-name: org.ow2.asm:asm
  dependency-version: 9.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.ow2.asm:asm-commons
  dependency-version: 9.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 07:17:30 +02:00
dependabot[bot] 1555cabff1 build(deps): bump org.apache.maven.plugins:maven-site-plugin (#1711)
Bumps [org.apache.maven.plugins:maven-site-plugin](https://github.com/apache/maven-site-plugin) from 3.21.0 to 3.22.0.
- [Release notes](https://github.com/apache/maven-site-plugin/releases)
- [Commits](https://github.com/apache/maven-site-plugin/compare/maven-site-plugin-3.21.0...maven-site-plugin-3.22.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-site-plugin
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 07:17:16 +02:00
dependabot[bot] c7b1ca3f0a build(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#1710)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.5 to 4.36.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.5...v4.36.0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 07:16:52 +02:00
Lukasz Lenart 26756ce85c security(versions): updates list of supported versions (#1708) 2026-05-22 04:47:05 +00:00
dependabot[bot] d628d949a0 build(deps): bump org.apache.logging.log4j:log4j-bom (#1685)
Bumps [org.apache.logging.log4j:log4j-bom](https://github.com/apache/logging-log4j2) from 2.25.4 to 2.26.0.
- [Release notes](https://github.com/apache/logging-log4j2/releases)
- [Changelog](https://github.com/apache/logging-log4j2/blob/2.x/RELEASE-NOTES.adoc)
- [Commits](https://github.com/apache/logging-log4j2/compare/rel/2.25.4...rel/2.26.0)

---
updated-dependencies:
- dependency-name: org.apache.logging.log4j:log4j-bom
  dependency-version: 2.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 10:06:49 +02:00
dependabot[bot] cc6b4cfd6e build(deps): bump asm.version from 9.9.1 to 9.10 (#1695)
Bumps `asm.version` from 9.9.1 to 9.10.

Updates `org.ow2.asm:asm` from 9.9.1 to 9.10

Updates `org.ow2.asm:asm-commons` from 9.9.1 to 9.10

---
updated-dependencies:
- dependency-name: org.ow2.asm:asm
  dependency-version: '9.10'
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: org.ow2.asm:asm-commons
  dependency-version: '9.10'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:18:23 +02:00
Lukasz Lenart f26325ff90 ci(dependabot): fix cooldown property name (#1702)
The `cooldown` block expects `default-days`, not `default`. Using the
wrong key fails schema validation, causing Dependabot to silently fall
back to the previously valid config — which still targets
`release/struts-6-8-x` instead of `support/struts-6-x-x`.

Also adds a 3-day cooldown to the `main` maven entry for consistency.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:07:05 +00:00
dependabot[bot] 1bd2951f7e build(deps-dev): bump org.apache.maven.plugins:maven-enforcer-plugin (#1698)
Bumps [org.apache.maven.plugins:maven-enforcer-plugin](https://github.com/apache/maven-enforcer) from 3.6.2 to 3.6.3.
- [Release notes](https://github.com/apache/maven-enforcer/releases)
- [Commits](https://github.com/apache/maven-enforcer/compare/enforcer-3.6.2...enforcer-3.6.3)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-enforcer-plugin
  dependency-version: 3.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 08:59:25 +02:00
dependabot[bot] 16618e0236 build(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#1694)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.4...v4.35.5)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 08:58:58 +02:00
Lukasz Lenart 419fb1f5c6 WW-5535 test(core): cover wildcard-resolved unannotated methods via real proxy (#1692)
Closes the test gap noted in the WW-5535 research: no integration test
exercised HttpMethodInterceptor against a real DefaultActionProxy resolving
a wildcard action with an unannotated method.

Uses xwork-test-allowed-methods.xml's existing <action name="Wild-*"
method="{1}"> on HttpMethodsTestAction. URL "Wild-execute" resolves to
ActionSupport.execute() (no method-level HTTP annotation); the class-level
@AllowedHttpMethod(POST) must still reject GET end-to-end.

Together with the prior MockActionProxy regression tests, this locks in
both halves of the fix:
- DefaultActionProxy.resolveMethod() sets isMethodSpecified()=true for
  wildcard-resolved methods (WW-5535 / #1592)
- HttpMethodInterceptor falls back to class-level annotations when the
  resolved method is unannotated (#1690)
2026-05-19 08:26:49 +00:00
ⳕⲛτⲉⲅⲥⲉⳏτⲟⲅ 🕵🏻 213b83f64f fix(core): enforce class-level HTTP method annotations for wildcard-resolved unannotated methods (#1690)
The WW-5535 fix (commit 4d2eb93) corrected isMethodSpecified() for wildcard-resolved
methods but introduced a structural gap in HttpMethodInterceptor.intercept().

The if/else-if structure made the class-level annotation check unreachable whenever
isMethodSpecified()=true and the resolved method carries no method-level annotation:

  if (isMethodSpecified()) {
      if (isAnnotatedBy(method)) { ... }
      // falls through silently
  } else if (isAnnotatedBy(class)) { ... }  // never reached
  return invocation.invoke();               // no enforcement

Fix: convert else-if to standalone if so the class-level check is always evaluated
as a fallback when the method itself has no annotation. Method-level annotations
still take precedence (checked first).

Add two regression tests covering the wildcard-resolved unannotated method scenario.

Co-authored-by: g0w6y <g0w6y@users.noreply.github.com>
2026-05-19 08:05:21 +02:00
Lukasz Lenart 7ea7911cf8 chore(rat): excludes Markdown files in docs/ folder (#1691) 2026-05-18 13:04:58 +00:00
Lukasz Lenart e83487f0e1 WW-5627 Gate CookieInterceptor through ParameterAuthorizer (#1681)
* WW-5627 add ParameterAllowlister interface and STRUTS_PARAMETER_ALLOWLISTER constant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5627 add OgnlParameterAllowlister default implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5627 register ParameterAllowlister bean in struts-default DI

* WW-5627 delegate ParametersInterceptor OGNL allowlisting to OgnlParameterAllowlister

Also register ParameterAllowlister in DefaultConfiguration bootstrap
factories so it is available in test containers (parallel to how
ParameterAuthorizer was already registered there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5627 test(cookie): failing test for unannotated setter skip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5627 gate CookieInterceptor cookie injection through ParameterAuthorizer

Adds a 5-arg `populateCookieValueIntoStack(name, value, map, stack, action)` hook
that runs cookie writes through `ParameterAuthorizer.isAuthorized` and primes
`ThreadAllowlist` via `ParameterAllowlister` for nested paths, then delegates
to the legacy 4-arg form. The 4-arg form is `@Deprecated(since="7.2.0")` but
its body is unchanged, so existing subclass overrides automatically receive
only authorized cookies. Default-config behavior is preserved because the
authorizer short-circuits when `requireAnnotations=false`.

Existing `CookieInterceptorTest` instantiates `new CookieInterceptor()` rather
than going through the container, leaving the new injected fields null. Wires
explicit pass-through lambdas through a `disableAuthorizationGate(...)` helper
so those tests continue to exercise default-config behavior.

* WW-5627 cover CookieInterceptor authorization matrix in CookieInterceptorAnnotationTest

* WW-5627 docs(cookie): document new 5-arg extension hook and deprecation

* WW-5627 wire OgnlParameterAllowlister in StrutsParameterAnnotationTest fixture

* WW-5627 address SonarCloud findings on PR #1681

- S1948: mark transient on the new ParameterAuthorizer/ParameterAllowlister
  fields in CookieInterceptor and ParametersInterceptor (the host classes
  are Serializable; the injected services are not).
- S1874: suppress the deprecation warning on the new 5-arg
  populateCookieValueIntoStack — the delegation to the deprecated 4-arg
  form is the contract that lets existing subclass overrides participate.
- S3776: extract `allowlistViaPropertyDescriptor` and
  `allowlistViaPublicField` from `OgnlParameterAllowlister.allowlistAuthorizedPath`
  to drop cognitive complexity below the threshold.
- S1068: remove the unused `mapping` test fixture field.

* WW-5627 clarify ParameterAllowlister contract and tidy ParametersInterceptor

Rename `ParameterAllowlister#allowlistAuthorizedPath` to `primeAllowlistForPath`
to make the contract explicit: the SAM is a side-effect-only priming hook that
runs after `ParameterAuthorizer#isAuthorized` has already decided. A no-op
return means "no priming needed or possible", never "rejected". The interface
name stays channel-agnostic; only the impl class (`OgnlParameterAllowlister`)
binds the priming to OGNL's `ThreadAllowlist`.

Add a `LOG.debug` in `OgnlParameterAllowlister` for the case where authorization
passed but no `@StrutsParameter` could be located on the root property
(e.g. `ModelDriven` models without per-property annotations) so the
authorize-vs-prime gap is observable instead of surfacing later as an opaque
OGNL traversal failure.

Drop the dead `performOgnlAllowlisting` pass-through and its unused `paramDepth`
parameter from `ParametersInterceptor` — the depth check is already enforced
inside `OgnlParameterAllowlister.primeAllowlistForPath`, so the outer guard was
a redundant computation.

No behavior change.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:52:17 +02:00
Lukasz Lenart 3432433e38 chore(agents): defines a new AGENTS.md focused on reporting vulnerabilities (#1680)
* chore(agents): defines a new AGENTS.md focused on reporting vulnerabilities

* chore(security): makes SECURITY.md the source of truth

* Fix typos in SECURITY.md and minor grammar correction

* chore(security): clarify public GitHub repo PoC counts as disclosure

Per @ppkarwasz review on #1680: expand the PoC bullet to make explicit
that pushing a PoC to a public GitHub repo, gist, fork, or branch is
public disclosure, and note that private repos require granting access
to each PMC member individually.

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

---------

Co-authored-by: René Gielen <rgielen@apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 13:27:53 +02:00
Lukasz Lenart 09d03286f8 WW-5626 per-property authorization for Jackson REST handlers (#1674)
* WW-5626 spike: validate Jackson per-property authorization mechanism

Validates that the Approach C design is feasible before committing to a detailed
implementation plan. Wraps each SettableBeanProperty via BeanDeserializerModifier;
intercepts deserializeAndSet to authorize against a path built from a ThreadLocal
Deque; uses skipChildren() to discard unauthorized values; uses [0] suffix for
collection/map/array elements to match ParametersInterceptor depth semantics.

Findings:
- Delegating base class via 'protected delegate' field is the right pattern
- addOrReplaceProperty(prop, true) is the correct builder API
- Reject-at-parent skips all nested deserialization (better security than two-phase
  copy: setter side effects on unauthorized properties never fire)
- JavaType#isCollectionLikeType/isMapLikeType/isArrayType detects the indexed-path case

Spike is kept under .../spike/ as a learning artifact; it will be replaced by
production code + tests in subsequent commits.

* WW-5626 add ParameterAuthorizationContext for deserializer-level authorization

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5626 address review feedback on ParameterAuthorizationContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5626 add AuthorizationAwareContentTypeHandler marker interface

* WW-5626 add AuthorizingSettableBeanProperty for Jackson per-property authorization

* WW-5626 add ParameterAuthorizingModule installing the property wrapper on Jackson mappers

* WW-5626 register ParameterAuthorizingModule on default Jackson REST handlers

* WW-5626 use AuthorizationAwareContentTypeHandler path when handler supports it

* WW-5626 add integration tests proving the new Jackson authorization path is used

* WW-5626 deprecate XStreamHandler in favor of JacksonXmlHandler

* WW-5626 remove Jackson auth spike; replaced by production tests

* WW-5626 make JuneauXmlHandler authorization-aware via post-parse walk

Implements AuthorizationAwareContentTypeHandler. When ParameterAuthorizationContext
is active (set by ContentTypeInterceptor when requireAnnotations=true), the handler
walks the parsed result tree and copies only authorized properties to the target,
descending into nested beans/collections/maps/arrays with indexed-path semantics
([0] suffix) for parity with ParametersInterceptor.

Note: Juneau parses the entire result tree before our walk runs, so setter side
effects on transient nested objects can fire even for unauthorized properties —
those transient objects are then discarded. This is functionally equivalent to the
legacy two-phase copy in ContentTypeInterceptor; only the Jackson handlers achieve
the stronger guarantee where unauthorized subtrees are never instantiated at all
(they use Jackson's BeanDeserializerModifier + skipChildren).

When no context is bound (default config), behavior is unchanged: parser.parse +
BeanUtils.copyProperties.

* WW-5626 add JuneauXmlHandler integration tests for @StrutsParameter authorization

* WW-5626 test(rest): cover JuneauXmlHandler post-parse walk for collections, maps, arrays

Sonar reported 51 uncovered new lines in JuneauXmlHandler (48.8% coverage on the
post-parse authorization walk — the security-critical code path the branch exists
to introduce). Add integration coverage for the previously-uncovered branches:

- collection-of-scalars (List<String> tags)
- collection-of-beans (List<Address> addresses)
- map-of-scalars (Map<String,String> attributes)
- array-of-scalars (String[] aliases)
- empty collection
- malformed XML wrapped as IOException

Also drop two unnecessary casts (Sonar S1905) on lines 243/252 — the unchecked
conversion happens at the return statement, the explicit casts were redundant
under the existing @SuppressWarnings("unchecked").

Add @Override on the inline AnyConstraintMatcher.matches override (Sonar S1161).

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

* WW-5626 test(rest): cover AuthorizingSettableBeanProperty builder-path deserialization

Sonar reported 11 uncovered new lines on AuthorizingSettableBeanProperty (66.7%
coverage). All 11 are in deserializeSetAndReturn — the alternate Jackson entry
point used for builder-pattern deserialization, never triggered by setter-based
fixtures like Person.

Add an @JsonDeserialize(builder=...) fixture (ImmutablePerson) that forces
Jackson to use BuilderBasedDeserializer, which dispatches property writes
through deserializeSetAndReturn. Three new tests exercise the path:
inactive-context pass-through, top-level authorization, and full rejection.

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

* WW-5626 refactor(rest): extract helpers from ContentTypeInterceptor.intercept

Sonar S3776 flagged intercept() at cognitive complexity 16 (limit 15). Extract
the body-handling branches into named helpers:

- openBodyReader: encoding-aware reader from the request InputStream
- applyRequestBody: dispatcher between requireAnnotations on/off paths
- applyWithAuthorizationContext: bind + delegate + unbind for AuthorizationAware handlers
- applyTwoPhaseDeserialize: legacy fresh-instance + copyAuthorizedProperties path

intercept() drops to ~12 lines and reads as a flat sequence: resolve target,
delegate body application, invoke. Each helper carries the comment that
explains the security model for its branch.

Add @Override on the inline AnyConstraintMatcher.matches override (Sonar S1161).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:01:00 +02:00
dependabot[bot] a48f57d161 build(deps): bump slf4j.version from 2.0.17 to 2.0.18 (#1683)
Bumps `slf4j.version` from 2.0.17 to 2.0.18.

Updates `org.slf4j:slf4j-api` from 2.0.17 to 2.0.18

Updates `org.slf4j:slf4j-simple` from 2.0.17 to 2.0.18

---
updated-dependencies:
- dependency-name: org.slf4j:slf4j-api
  dependency-version: 2.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.slf4j:slf4j-simple
  dependency-version: 2.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 14:58:10 +02:00
dependabot[bot] 9c8d3de0e4 build(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#1682)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.3...v4.35.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 14:57:50 +02:00
dependabot[bot] e5aa3e98a6 build(deps): bump com.github.ben-manes.caffeine:caffeine (#1679)
Bumps [com.github.ben-manes.caffeine:caffeine](https://github.com/ben-manes/caffeine) from 3.2.3 to 3.2.4.
- [Release notes](https://github.com/ben-manes/caffeine/releases)
- [Commits](https://github.com/ben-manes/caffeine/compare/v3.2.3...v3.2.4)

---
updated-dependencies:
- dependency-name: com.github.ben-manes.caffeine:caffeine
  dependency-version: 3.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 13:49:55 +02:00
dependabot[bot] 56b9b6807e build(deps): bump org.owasp:dependency-check-maven from 12.2.1 to 12.2.2 (#1677)
Bumps [org.owasp:dependency-check-maven](https://github.com/dependency-check/DependencyCheck) from 12.2.1 to 12.2.2.
- [Release notes](https://github.com/dependency-check/DependencyCheck/releases)
- [Changelog](https://github.com/dependency-check/DependencyCheck/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dependency-check/DependencyCheck/compare/v12.2.1...v12.2.2)

---
updated-dependencies:
- dependency-name: org.owasp:dependency-check-maven
  dependency-version: 12.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 13:49:01 +02:00
dependabot[bot] fd05da73fa build(deps): bump org.glassfish.jaxb:jaxb-bom from 4.0.7 to 4.0.8 (#1676)
Bumps org.glassfish.jaxb:jaxb-bom from 4.0.7 to 4.0.8.

---
updated-dependencies:
- dependency-name: org.glassfish.jaxb:jaxb-bom
  dependency-version: 4.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 13:48:48 +02:00
dependabot[bot] c9cfa36cb2 build(deps): bump github/codeql-action from 4.35.2 to 4.35.3 (#1675)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.2 to 4.35.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.2...v4.35.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 13:48:28 +02:00
Lukasz Lenart 690c4c2737 WW-5626 cleanup follow-ups for @StrutsParameter JSON/REST enforcement (#1673)
* WW-5626 add ParameterAuthorizer#resolveTarget for centralized ModelDriven resolution

Move the ValueStack peek logic that derives the target object from action+ModelDriven
state out of ParametersInterceptor and into ParameterAuthorizer. Callers that need
both authorization and the resolved target (for downstream OGNL allowlisting) can
now call resolveTarget once and reuse the result.

* WW-5626 delegate ModelDriven target resolution to ParameterAuthorizer

Replace the inline ValueStack peek in ParametersInterceptor#isParameterAnnotatedAndAllowlist
with a call to ParameterAuthorizer#resolveTarget. The ModelDriven import is no longer
needed in this class.

* WW-5626 defensively skip non-String JSON keys in authorization filter

The (String) cast in filterUnauthorizedKeysRecursive threw ClassCastException
for any custom JSONReader producing non-String keys. Replace with an instanceof
pattern that debug-logs and skips entries whose key cannot be converted to a
parameter path.

* WW-5626 add real JacksonJsonHandler integration tests for @StrutsParameter filtering

The existing ContentTypeInterceptorTest uses mock ContentTypeHandlers, so its
requireAnnotations=true tests verify only that intercept() returns SUCCESS — they
assert nothing about which properties were actually filtered. These integration
tests use a real JacksonJsonHandler + a real StrutsParameterAuthorizer to verify
end-to-end property-level filtering for top-level annotated/unannotated properties
and nested properties at varying authorized depths.

The SecureRestAction fixture documents a semantic divergence: REST's recursive
copy authorizes each path level independently, so depth-0 authorization on the
top-level property requires @StrutsParameter on the setter even when nested
field access is the actual goal. ParametersInterceptor only requires the getter
annotation. This divergence is tracked for the Approach C refactor.

* WW-5626 make ParameterAuthorizer#resolveTarget a default method to preserve SAM

Making resolveTarget abstract broke ParameterAuthorizer as a functional interface,
which the existing JSON and REST plugin tests rely on for lambda-based stubs:
  interceptor.setParameterAuthorizer((parameterName, target, action) -> true);

The default returns the action unchanged — adequate for lambda test stubs whose
authorization decisions don't depend on the resolved target. The production
implementation (StrutsParameterAuthorizer) overrides this with the proper
ModelDriven value-stack peek.
2026-05-09 12:09:03 +02:00
dependabot[bot] 7799c0fd2a build(deps): bump commons-io:commons-io from 2.21.0 to 2.22.0 (#1672)
Bumps commons-io:commons-io from 2.21.0 to 2.22.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  dependency-version: 2.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 16:00:19 +02:00
dependabot[bot] 11e815f26b build(deps): bump com.fasterxml.jackson:jackson-bom (#1669)
Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.21.2 to 2.21.3.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.21.2...jackson-bom-2.21.3)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson:jackson-bom
  dependency-version: 2.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 15:56:23 +02:00
dependabot[bot] 664540cc84 build(deps): bump github/codeql-action from 4.35.1 to 4.35.2 (#1668)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.1 to 4.35.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.1...v4.35.2)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 15:56:03 +02:00
quactv c3a887085d WW-5624: Enforce @StrutsParameter on JSON/REST body deserialization (#1657)
* WW-5624 fix(security): enforce @StrutsParameter on JSON/REST body deserialization

Extract ParameterAuthorizer service from ParametersInterceptor to share
@StrutsParameter annotation enforcement across all input channels.

The json-plugin (JSONInterceptor) and rest-plugin (ContentTypeInterceptor)
previously bypassed @StrutsParameter checks when deserializing request
bodies, allowing mass assignment even when
struts.parameters.requireAnnotations=true.

Changes:
- New ParameterAuthorizer interface and DefaultParameterAuthorizer impl
- JSONInterceptor: filter unauthorized Map keys before populateObject()
- ContentTypeInterceptor: two-phase deserialization (fresh instance then
  copy authorized properties) when requireAnnotations=true; direct
  deserialization for backward compat when disabled
- OGNL ThreadAllowlist side effects remain in ParametersInterceptor only
- Full DI wiring: struts-beans.xml + StrutsBeanSelectionProvider +
  DefaultConfiguration
- 15 new unit tests for ParameterAuthorizer, 2 for JSON plugin,
  2 for REST plugin; 32 existing regression tests verified

* WW-5624 address review feedback from lukaszlenart on PR #1657

1. Rename DefaultParameterAuthorizer → StrutsParameterAuthorizer
   per Struts naming convention (inline suggestion)

2. Narrow ModelDriven exemption: require action instanceof ModelDriven
   before exempting target from @StrutsParameter checks. Prevents
   non-ModelDriven root objects (e.g. JSONInterceptor.root) from
   bypassing annotation enforcement.

3. Recursive JSON key filtering: filterUnauthorizedKeys() now recurses
   into nested Maps and Lists, building dot-notation paths (e.g.
   "address.city") for path-aware @StrutsParameter(depth=N) checks.

4. Deep REST property copy: copyAuthorizedProperties() now recurses
   into nested bean types with path-aware authorization. Collections,
   Maps, primitives, and java.lang/java.time types are copied directly.

5. Null-skip semantics preserved and documented: in two-phase
   deserialization, null in freshInstance is indistinguishable from
   "not present in request" — clearing would destroy pre-initialized
   fields. Kept as intentional design choice with inline documentation.

6. No-arg constructor fallback: when target class lacks a no-arg
   constructor, falls back to single-phase deserialization with
   post-scrub of unauthorized properties, preserving backward compat.

7. New regression tests:
   - Non-ModelDriven target with different object (must not exempt)
   - Nested JSON keys recursively filtered
   - Non-action root object still checked by authorizer

All 280+ core tests, 124 JSON tests, 76 REST tests pass with 0 regressions.

* WW-5624: v3 — fix indexed-path depth parity with ParametersInterceptor

Four gaps identified by lukaszlenart's April 10 review are now fully addressed:

1. JSON filterUnauthorizedList: pass prefix+"[0]" instead of bare prefix so
   that list element properties gain one extra '[' in their path — e.g.
   "publicPojoListDepthOne[0].key" (depth=2) is now correctly rejected when
   @StrutsParameter(depth=1), matching ParametersInterceptor semantics.
   Also recurse into nested List<List<Map>> via an else-if branch.

2. REST copyAuthorizedProperties: add authTarget parameter (always = root
   action/model, passed unchanged through all recursion levels).
   isAuthorized() now checks the full path against the root class, so
   "address.city" is looked up on the action, not on the Address object.

3. REST Collection/Map/array deep authorization: replaced the as-is copy
   with deepCopyAuthorizedCollection(), deepCopyAuthorizedMap(), and
   deepCopyAuthorizedArray() helpers — each iterates elements with
   path+"[0]" prefix, authorizing every complex element individually.
   No-arg fallback skips the element rather than copying an unfiltered
   object graph (security fix over plan's original as-is suggestion).

4. REST scrubUnauthorizedProperties: now fully recursive via
   scrubUnauthorizedPropertiesRecursive() — visits nested beans,
   collection elements, and map values with authTarget always pointing
   to the root. Includes identity-based visited-set to guard against
   circular reference cycles.

Tests: core 2920 + json 124 + rest 76 = 3120, 0 failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5624: v3.1 — fix collection type, identity set, isNestedBeanType coverage

Three correctness/security issues identified by independent review:

1. deepCopyAuthorizedCollection/deepCopyAuthorizedMap type preservation:
   Previously always returned ArrayList/LinkedHashMap. If the action field
   is typed Set<Pojo> or SortedMap<K,V>, writeMethod.invoke would throw
   IllegalArgumentException. Now: SortedSet→TreeSet, Set→LinkedHashSet,
   List→ArrayList; SortedMap→TreeMap, Map→LinkedHashMap.

2. scrubUnauthorizedPropertiesRecursive visited-set identity safety:
   Replaced Set<Integer>+System.identityHashCode (not collision-safe) with
   Collections.newSetFromMap(new IdentityHashMap<>()) which uses reference
   equality (==). A hash collision could have caused a valid nested object
   to be skipped, leaving unauthorized properties un-scrubbed.

3. isNestedBeanType now excludes all standard-library leaf packages:
   java.util.* non-Collection/Map types (UUID, Currency, Locale, Date),
   java.time.* (all temporal types, not just Temporal subinterface),
   java.net.*, java.io.*, java.nio.*. Previously UUID etc. would return
   true, causing the code to recurse into their internal fields and silently
   drop the value when no @StrutsParameter annotation matched.

Tests: json 124 + rest 76 = 200, 0 failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WW-5624: v4 — close bulk-copy fallback, reject body when no no-arg ctor

Two remaining gaps addressed per lukaszlenart's April 11 review:

1. copyAuthorizedProperties bulk-copy fallback removed:
   When a nested target bean is null and createFreshInstance fails (no
   no-arg constructor), the previous code fell back to
   writeMethod.invoke(target, sourceValue) — copying the whole nested
   object graph without per-path authorization. Now logs a warning and
   skips the property entirely (same policy as deepCopyAuthorizedCollection
   elements with no no-arg constructor).

2. Top-level no-arg constructor fallback changed from scrub to reject:
   When requireAnnotations=true and the target class has no no-arg
   constructor, body deserialization is now rejected entirely
   (handler.toObject is never called). The previous best-effort scrub
   path could not guarantee that all nested unauthorized properties were
   nulled out. scrubUnauthorizedProperties and its recursive helper are
   removed as dead code.

Tests: rest 76, 0 failures.

---------

Co-authored-by: tranquac <tranquac@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:46:38 +02:00
quactv 8d6f13904f fix(core): WW-5623 HTML-encode form action in PostbackResult to prevent XSS (#1653)
* fix(core): HTML-encode form action in PostbackResult to prevent XSS

PostbackResult.doExecute() embeds finalLocation into a <form action="">
attribute via raw string concatenation without HTML encoding. A double
quote in the location breaks out of the attribute, enabling reflected
XSS. The response Content-Type is text/html (line 103).

This is an encoding inconsistency: form field names and values at lines
218-219 ARE properly URL-encoded via URLEncoder.encode(), but the form
action attribute was not encoded at all.

Add encodeHtml() to escape &, ", <, > in finalLocation before embedding
it in the HTML form tag, consistent with the existing encoding approach
for form field values in the same class.

* fix(core): WW-5623 use StringEscapeUtils and add regression tests

Address review feedback from @lukaszlenart:

- Replace custom encodeHtml() with StringEscapeUtils.escapeHtml4()
  for consistency with the rest of Struts core (DefaultActionProxy,
  Property, TextProviderHelper all use StringEscapeUtils)
- Add 3 focused unit tests in PostbackResultTest:
  - testFormActionHtmlEscaping: XSS payload with attribute breakout
  - testFormActionEscapesAllHtmlSpecialChars: covers ", &, <, >
  - testFormActionCleanLocationUnchanged: regression for clean URLs

---------

Co-authored-by: tranquac <tranquac@users.noreply.github.com>
2026-05-01 10:42:25 +02:00
aaaZayne f4c6349283 introduce private method to remove clones (#1666)
* introduce private method to remove clones

* Update naming
2026-04-20 04:57:28 +00:00
dependabot[bot] 71d4e062d3 build(deps): bump org.owasp:dependency-check-maven from 12.2.0 to 12.2.1 (#1664)
Bumps [org.owasp:dependency-check-maven](https://github.com/dependency-check/DependencyCheck) from 12.2.0 to 12.2.1.
- [Release notes](https://github.com/dependency-check/DependencyCheck/releases)
- [Changelog](https://github.com/dependency-check/DependencyCheck/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dependency-check/DependencyCheck/compare/v12.2.0...v12.2.1)

---
updated-dependencies:
- dependency-name: org.owasp:dependency-check-maven
  dependency-version: 12.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 11:06:10 +02:00
dependabot[bot] 862019e8f6 build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#1663)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 11:06:00 +02:00
Lukasz Lenart 396d7b716d cd(checks): uses proper context names for checks (#1660) 2026-04-10 05:42:32 +00:00
Lukasz Lenart 5f7a9129fc ci(struts6): adjusts workflows to use the new branch (#1659) 2026-04-10 07:16:13 +02:00
dependabot[bot] 32c80b22bb build(deps): bump ognl:ognl from 3.4.10 to 3.4.11 (#1655)
Bumps [ognl:ognl](https://github.com/orphan-oss/ognl) from 3.4.10 to 3.4.11.
- [Release notes](https://github.com/orphan-oss/ognl/releases)
- [Commits](https://github.com/orphan-oss/ognl/commits)

---
updated-dependencies:
- dependency-name: ognl:ognl
  dependency-version: 3.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 18:45:13 +02:00
Lukasz Lenart 89ed075b30 ci(dependabot): add cooldown (#1656) 2026-04-08 15:47:58 +00:00
96 changed files with 5214 additions and 135 deletions
+11 -2
View File
@@ -23,11 +23,20 @@ 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
release/*:
support/struts-6-x-x:
# contexts are the names of checks that must pass.
required_status_checks:
contexts:
- "Build and Test (JDK 8)"
- "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)"
required_pull_request_reviews:
# it does not work because our github teams are private/secret, see INFRA-25666
require_code_owner_reviews: false
@@ -0,0 +1,90 @@
---
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. |
+5 -1
View File
@@ -8,12 +8,16 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
target-branch: "main"
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
target-branch: "release/struts-6-8-x"
cooldown:
default-days: 4
target-branch: "support/struts-6-x-x"
ignore:
- dependency-name: "org.eclipse.jetty:jetty-maven-plugin"
- dependency-name: "com.github.ben-manes.caffeine:caffeine"
+4 -3
View File
@@ -20,6 +20,7 @@ on:
branches:
- 'main'
- 'release/*'
- 'support/*'
pull_request:
permissions:
@@ -52,12 +53,12 @@ jobs:
java-version: 17
cache: 'maven'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.35.1
uses: github/codeql-action/init@v4.36.2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4.35.1
uses: github/codeql-action/autobuild@v4.36.2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.35.1
uses: github/codeql-action/analyze@v4.36.2
with:
category: "/language:${{matrix.language}}"
+1
View File
@@ -21,6 +21,7 @@ on:
branches:
- 'main'
- 'release/*'
- 'support/*'
permissions: read-all
+2 -2
View File
@@ -58,13 +58,13 @@ jobs:
publish_results: true
- name: "Upload artifact"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 7.0.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@c618c9bddbf8ce520050acf14e9bb6c220e22931 # 2.22.11
uses: github/codeql-action/upload-sarif@eda5730a8bfb740e03a28087a958444c646e5842 # 2.22.11
with:
sarif_file: results.sarif
+30
View File
@@ -0,0 +1,30 @@
# 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.
+11 -1
View File
@@ -63,6 +63,13 @@ 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`.
@@ -71,4 +78,7 @@ 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
- **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).
Vendored
+3 -3
View File
@@ -105,7 +105,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'release/struts-6-8-x'
branch 'support/struts-6-x-x'
}
}
steps {
@@ -119,7 +119,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'release/struts-6-8-x'
branch 'support/struts-6-x-x'
}
}
steps {
@@ -132,7 +132,7 @@ pipeline {
when {
anyOf {
branch 'main'
branch 'release/struts-6-8-x'
branch 'support/struts-6-x-x'
}
}
steps {
+93 -7
View File
@@ -5,13 +5,17 @@
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 | :white_check_mark: |
| 6.7.x | :white_check_mark: |
| 2.5.x | |
| 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 |
## Reporting New Security Issues with thr Apache Struts
## Reporting New Security Issues with the Apache Struts
([original](https://struts.apache.org/security.html))
@@ -29,7 +33,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.
@@ -38,3 +42,85 @@ The mailing address is: [security@struts.apache.org](mailto:security@struts.apac
[General network server security tips](http://httpd.apache.org/docs/trunk/misc/security_tips.html)
[The Apache Security Team](http://www.apache.org/security/)
## Before Reporting
Before sending a vulnerability report, run through the following checks. They exist to prevent duplicate reports, public disclosure of untriaged issues,
and reports for behavior that is already documented as insecure configuration.
### 1. Read this policy
Confirm:
- which Struts versions are currently supported (see [Supported Versions](#supported-versions)),
- where reports must be sent (see [Reporting New Security Issues](#reporting-new-security-issues-with-the-apache-struts)),
- which reports do not belong on the private security list.
### 2. Read the Struts security guidelines
Review the [Struts security guidance](https://struts.apache.org/security/) and determine whether the finding is already covered by documented secure
configuration or application guidance, including but not limited to:
- Config Browser Plugin exposure,
- direct JSP access,
- `devMode` is required to exploit the vulnerability,
- `@StrutsParameter` usage and parameter annotation requirements,
- unsafe setters or getters exposed to request parameters,
- use of incoming values in localization or forced OGNL evaluation,
- raw JSP EL expressions,
- custom error pages,
- Dynamic Method Invocation and Strict Method Invocation,
- accepted and excluded parameter patterns,
- Fetch Metadata, COOP, and COEP protections,
- OGNL sandboxing, allowlists, excluded classes/packages, and OGNL Guard settings.
If the behavior is caused by an application ignoring documented security guidance, that is not an Apache Struts framework vulnerability.
### 3. Check previously disclosed vulnerabilities
Compare the finding against already disclosed Struts vulnerabilities — affected versions, impact ratings, mitigations, and fixed versions:
- [Struts security information](https://struts.apache.org/security/)
- [Prior releases and vulnerability notes](https://struts.apache.org/releases.html#prior-releases)
- [Security Bulletins (S2 series)](https://cwiki.apache.org/confluence/display/WW/Security+Bulletins)
If the finding overlaps with a known vulnerability, link to the existing bulletin, advisory, CVE, or release notes instead of drafting a new report.
## Assessment
Before drafting a report, confirm:
1. Is the affected version supported?
2. Is the behavior in Apache Struts framework code, rather than only in an application using Struts?
3. Is it already documented as insecure configuration or unsupported usage?
4. Is it a duplicate of a previously disclosed vulnerability or Security Bulletin?
5. Can the impact be demonstrated with a minimal, self-contained reproduction?
Only proceed with a private report when these answers still point to a likely new vulnerability in the framework.
## Private Report Requirements
A useful private report includes:
- affected Struts version or version range,
- affected component or module,
- required application configuration, if any,
- minimal reproduction steps,
- expected behavior,
- actual behavior,
- demonstrated security impact,
- whether authentication or special privileges are required,
- proposed fix or mitigation, if known.
Do not speculate beyond what can be demonstrated. If severity is uncertain, say so explicitly.
## Report Quality Rules
- One vulnerability per report.
- Keep reproduction steps minimal and self-contained.
- Do not include unrelated findings.
- Do not publish exploit details or proof-of-concept code publicly before the Struts project has triaged the issue. **Pushing a PoC to a public GitHub
repository, gist, fork, or branch counts as public disclosure** — even a "test" or throwaway repo. Private repositories are acceptable for sharing a PoC,
but access must be granted individually to each PMC member who will triage the report.
- Do not send ordinary bugs, usage questions, or generic denial-of-service concerns to the private security list.
- If the issue is not a vulnerability in Apache Struts source code, use the appropriate public support or issue channel instead.
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-apps</artifactId>
+2 -2
View File
@@ -24,12 +24,12 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-apps</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-rest-showcase</artifactId>
<packaging>war</packaging>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<name>Struts 2 Rest Showcase Webapp</name>
<description>Struts 2 Rest Showcase Example</description>
+2 -2
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-apps</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</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.5</version>
<version>3.5.6</version>
<configuration>
<includes>
<include>it.org.apache.struts2.showcase.*Test</include>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-project</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-bom</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-core</artifactId>
@@ -551,6 +551,21 @@ 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
*
@@ -719,6 +734,7 @@ 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,6 +73,8 @@ 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;
@@ -446,6 +448,8 @@ 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,6 +92,10 @@ 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;
@@ -406,6 +410,8 @@ public class DefaultConfiguration implements Configuration {
.factory(BeanInfoCacheFactory.class, DefaultOgnlBeanInfoCacheFactory.class, Scope.SINGLETON)
.factory(ProxyCacheFactory.class, StrutsProxyCacheFactory.class, Scope.SINGLETON)
.factory(ProxyService.class, StrutsProxyService.class, Scope.SINGLETON)
.factory(ParameterAuthorizer.class, StrutsParameterAuthorizer.class, Scope.SINGLETON)
.factory(ParameterAllowlister.class, OgnlParameterAllowlister.class, Scope.SINGLETON)
.factory(OgnlUtil.class, Scope.SINGLETON)
.factory(SecurityMemberAccess.class, Scope.PROTOTYPE)
.factory(OgnlGuard.class, StrutsOgnlGuard.class, Scope.SINGLETON)
@@ -19,6 +19,7 @@
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;
@@ -32,6 +33,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.StrutsException;
import org.apache.struts2.dispatcher.LocalizedMessage;
import org.apache.struts2.inject.Inject;
@@ -60,6 +62,12 @@ 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.
*/
@@ -211,6 +219,7 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
}
protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset charset, Path saveDir) {
ensureFileUploadApiVerified();
JakartaServletDiskFileUpload servletFileUpload = createJakartaFileUpload(charset, saveDir);
if (maxSize != null) {
@@ -228,6 +237,48 @@ 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);
}
@@ -241,9 +292,7 @@ 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()});
if (!errors.contains(localizedMessage)) {
errors.add(localizedMessage);
}
addErrorIfAbsent(localizedMessage);
return true;
}
return false;
@@ -278,15 +327,17 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
}
LocalizedMessage errorMessage = buildErrorMessage(exClass, e.getMessage(), args);
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
addErrorIfAbsent(errorMessage);
} catch (IOException e) {
LOG.warn("Unable to parse request", e);
LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{});
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
addErrorIfAbsent(errorMessage);
}
}
private void addErrorIfAbsent(LocalizedMessage errorMessage) {
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
}
@@ -449,9 +500,7 @@ public abstract class AbstractMultiPartRequest implements MultiPartRequest {
"Empty files are not allowed",
new Object[]{fileName, fieldName}
);
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
addErrorIfAbsent(errorMessage);
return true;
}
return false;
@@ -18,12 +18,15 @@
*/
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;
@@ -32,12 +35,16 @@ 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;
/**
@@ -68,6 +75,9 @@ import java.util.Map;
* <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>
@@ -135,6 +145,9 @@ 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) {
@@ -146,6 +159,21 @@ 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);
@@ -175,15 +203,64 @@ public class ChainingInterceptor extends AbstractInterceptor {
List<Object> list = prepareList(root);
Map<String, Object> ctxMap = invocation.getInvocationContext().getContextMap();
for (Object object : list) {
if (!shouldCopy(object)) {
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) {
continue;
}
Object action = invocation.getAction();
Class<?> editable = null;
if (proxyService.isProxy(action)) {
editable = proxyService.ultimateTargetClass(action);
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);
}
reflectionProvider.copy(object, action, ctxMap, prepareExcludes(), includes, editable);
}
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;
}
}
@@ -26,6 +26,8 @@ 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;
@@ -99,8 +101,16 @@ import java.util.Set;
*
* <ul>
* <li>
* populateCookieValueIntoStack - this method will decide if this cookie value is qualified
* to be populated into the value stack (hence into the action itself)
* 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.
* </li>
* <li>
* injectIntoCookiesAwareAction - this method will inject selected cookies (as a java.util.Map)
@@ -187,6 +197,8 @@ 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) {
@@ -199,6 +211,16 @@ 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.
@@ -234,6 +256,8 @@ 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<>();
@@ -248,9 +272,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);
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
} else if (cookiesNameSet.contains(cookie.getName())) {
populateCookieValueIntoStack(name, value, cookiesMap, stack);
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
}
} else {
LOG.warn("Cookie name [{}] with value [{}] was rejected!", name, value);
@@ -259,7 +283,7 @@ public class CookieInterceptor extends AbstractInterceptor {
}
// inject the cookiesMap, even if we don't have any cookies
injectIntoCookiesAwareAction(invocation.getAction(), cookiesMap);
injectIntoCookiesAwareAction(action, cookiesMap);
return invocation.invoke();
}
@@ -314,6 +338,30 @@ 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).
@@ -322,7 +370,12 @@ public class CookieInterceptor extends AbstractInterceptor {
* @param cookieValue cookie value
* @param cookiesMap map of cookies
* @param stack value stack
* @deprecated since 7.2.0. Override
* {@link #populateCookieValueIntoStack(String, String, Map, ValueStack, Object)} instead so cookie writes are
* authorized by {@link ParameterAuthorizer}. The default 5-arg implementation calls this method after the
* authorization gate, so existing overrides continue to receive only authorized cookies.
*/
@Deprecated(since = "7.2.0")
protected void populateCookieValueIntoStack(String cookieName, String cookieValue, Map<String, String> cookiesMap, ValueStack stack) {
if (cookiesValueSet.isEmpty() || cookiesValueSet.contains("*")) {
// If the interceptor is configured to accept any cookie value
@@ -90,7 +90,8 @@ public class HttpMethodInterceptor extends AbstractInterceptor {
invocation.getProxy().getMethod(), AllowedHttpMethod.class.getSimpleName(), request.getMethod());
return doIntercept(invocation, method);
}
} else if (AnnotationUtils.isAnnotatedBy(action.getClass(), HTTP_METHOD_ANNOTATIONS)) {
}
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());
@@ -0,0 +1,192 @@
/*
* 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;
}
}
}
@@ -0,0 +1,44 @@
/*
* 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);
}
@@ -0,0 +1,144 @@
/*
* 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;
}
}
}
@@ -0,0 +1,68 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.interceptor.parameter;
/**
* Service for determining whether a given parameter name is authorized for injection into a target object, based on
* {@link StrutsParameter} annotation presence and depth.
*
* <p>This service extracts the authorization logic from {@link ParametersInterceptor} so that it can be reused by other
* input channels (e.g. JSON plugin, REST plugin) that also need to enforce {@code @StrutsParameter} rules.</p>
*
* <p>Implementations must NOT perform OGNL ThreadAllowlist side effects — those remain specific to
* {@link ParametersInterceptor}.</p>
*
* @since 7.2.0
*/
public interface ParameterAuthorizer {
/**
* Determines whether a parameter with the given name is authorized for injection into the given target object.
*
* <p>When {@code struts.parameters.requireAnnotations} is {@code false}, this method always returns {@code true}
* for backward compatibility.</p>
*
* @param parameterName the parameter name (e.g. "name", "address.city", "items[0].name")
* @param target the object receiving the parameter value (the action, or the model for ModelDriven actions)
* @param action the action instance; used to detect ModelDriven exemption (when {@code target != action},
* the target is the model and is exempt from annotation requirements)
* @return {@code true} if the parameter is authorized for injection, {@code false} otherwise
*/
boolean isAuthorized(String parameterName, Object target, Object action);
/**
* Resolves the target object whose annotations should be checked for authorization.
* For {@link org.apache.struts2.ModelDriven} actions, the default implementation returns the action itself;
* the production implementation ({@link StrutsParameterAuthorizer}) overrides this to return the model from
* the value stack.
*
* <p>Callers that need both authorization checks AND the resolved target (e.g. for downstream OGNL allowlisting)
* should call this once and reuse the result.</p>
*
* <p>This is a {@code default} method to preserve the interface as a functional interface (SAM) for
* lambda-based test stubs.</p>
*
* @param action the action instance
* @return the resolved target — either the action or its model
* @since 7.2.0
*/
default Object resolveTarget(Object action) {
return action;
}
}
@@ -23,7 +23,6 @@ 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;
@@ -66,10 +65,7 @@ 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;
@@ -100,6 +96,8 @@ 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) {
@@ -121,6 +119,16 @@ 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);
@@ -352,6 +360,9 @@ 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.
*/
@@ -360,23 +371,15 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
return true;
}
long paramDepth = name.codePoints().mapToObj(c -> (char) c).filter(NESTING_CHARS::contains).count();
Object target = parameterAuthorizer.resolveTarget(action);
if (action instanceof ModelDriven<?> && !ActionContext.getContext().getValueStack().peek().equals(action)) {
LOG.debug("Model driven Action detected, exempting from @StrutsParameter annotation requirement");
return true;
// Delegate authorization check to shared ParameterAuthorizer (no OGNL side effects)
if (!parameterAuthorizer.isAuthorized(name, target, action)) {
return false;
}
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);
parameterAllowlister.primeAllowlistForPath(name, target);
return true;
}
/**
@@ -0,0 +1,244 @@
/*
* 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;
}
}
}
@@ -23,6 +23,7 @@ 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;
@@ -104,7 +105,7 @@ public class PostbackResult extends StrutsResultSupport {
// Render
PrintWriter pw = new PrintWriter(response.getOutputStream());
pw.write("<!DOCTYPE html><html><body><form action=\"" + finalLocation + "\" method=\"POST\">");
pw.write("<!DOCTYPE html><html><body><form action=\"" + StringEscapeUtils.escapeHtml4(finalLocation) + "\" method=\"POST\">");
writeFormElements(request, pw);
writePrologueScript(pw);
pw.write("</html>");
@@ -26,6 +26,7 @@ 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;
@@ -248,7 +249,7 @@ public class ServletRedirectResult extends StrutsResultSupport implements Reflec
response.setStatus(statusCode);
response.setHeader("Location", finalLocation);
try {
response.getWriter().write(finalLocation);
response.getWriter().write(StringEscapeUtils.escapeHtml4(finalLocation));
} finally {
response.getWriter().close();
}
@@ -18,6 +18,8 @@
*/
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;
@@ -33,6 +35,17 @@ 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() {
}
@@ -73,7 +86,7 @@ public class ConfigParseUtil {
Set<Class<?>> classes = new HashSet<>();
for (String className : classNames) {
try {
classes.add(validatingClassLoader.loadClass(className));
classes.add(loadAndCacheClass(validatingClassLoader, className));
} catch (ClassNotFoundException e) {
throw new ConfigurationException("Cannot load class for exclusion/exemption configuration: " + className, e);
}
@@ -81,6 +94,35 @@ 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,6 +18,7 @@
*/
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;
@@ -29,6 +30,7 @@ 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
@@ -186,10 +188,15 @@ 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 session token {1}.", new Object[]{
token, sessionToken
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)
}));
}
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;
}
@@ -257,6 +257,11 @@ 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 session token {1}.
struts.internal.invalid.token=Form token {0} does not match the expected session token.
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 token som findes i session {1}.
struts.internal.invalid.token=Form token {0} passer ikke med den forventede session-token.
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 Session Token {1} \u00FCberein.
struts.internal.invalid.token=Das Formular-Token {0} stimmt nicht mit dem erwarteten Session-Token \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 session token {1}.
struts.internal.invalid.token=Form token {0} does not match the expected session token.
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 tokena sesji {1}.
struts.internal.invalid.token=Token formularza {0} nie pasuje do oczekiwanego tokena sesji.
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 {1}.
struts.internal.invalid.token=O token do formul\u00E1rio {0} \u00E9 diferente do token de sess\u00E3o esperado.
struts.messages.bypass.request=Ignorando {0}/ {1}
struts.messages.current.file=Arquivo {0} {1} {2} {3}
+6
View File
@@ -245,6 +245,12 @@
<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"
@@ -0,0 +1,47 @@
/*
* 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 {
}
}
@@ -0,0 +1,45 @@
/*
* 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,7 +27,13 @@ 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.*;
/**
@@ -149,6 +155,151 @@ 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();
@@ -0,0 +1,219 @@
/*
* 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,6 +43,15 @@ 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();
@@ -68,6 +77,7 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
CookieInterceptor interceptor = new CookieInterceptor();
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.intercept(invocation);
@@ -105,6 +115,7 @@ 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);
@@ -147,6 +158,7 @@ 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);
@@ -188,6 +200,7 @@ 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);
@@ -230,6 +243,7 @@ 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);
@@ -271,6 +285,7 @@ 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);
@@ -313,6 +328,7 @@ 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);
@@ -395,6 +411,7 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
excludedPatternsChecker.setAdditionalExcludePatterns(".*(^|\\.|\\[|'|\")class(\\.|\\[|'|\").*");
interceptor.setExcludedPatternsChecker(excludedPatternsChecker);
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("*");
MockActionInvocation invocation = new MockActionInvocation();
@@ -441,6 +458,7 @@ public class CookieInterceptorTest extends StrutsInternalTestCase {
};
interceptor.setExcludedPatternsChecker(new DefaultExcludedPatternsChecker());
interceptor.setAcceptedPatternsChecker(new DefaultAcceptedPatternsChecker());
disableAuthorizationGate(interceptor);
interceptor.setCookiesName("*");
MockActionInvocation invocation = new MockActionInvocation();
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.interceptor;
import org.apache.struts2.action.Action;
/**
* Test fixture: target action whose {@code managerApproved} property is NOT annotated with
* {@code @StrutsParameter}. Used by {@link ChainingInterceptorTest}.
*/
public class UnannotatedChainingAction implements Action {
private boolean managerApproved;
public boolean getManagerApproved() {
return managerApproved;
}
public void setManagerApproved(boolean managerApproved) {
this.managerApproved = managerApproved;
}
@Override
public String execute() {
return SUCCESS;
}
}
@@ -19,6 +19,8 @@
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;
@@ -26,6 +28,8 @@ 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;
@@ -273,6 +277,83 @@ 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);
@@ -0,0 +1,154 @@
/*
* 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();
}
}
}
@@ -0,0 +1,129 @@
/*
* 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();
}
}
@@ -0,0 +1,275 @@
/*
* 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; }
}
}
@@ -56,6 +56,7 @@ import static org.mockito.Mockito.when;
public class StrutsParameterAnnotationTest {
private ParametersInterceptor parametersInterceptor;
private StrutsParameterAuthorizer parameterAuthorizer;
private ThreadAllowlist threadAllowlist;
@@ -76,6 +77,19 @@ 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()));
@@ -360,6 +374,7 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotated_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new FieldAction(), "publicStrNotAnnotated", true);
}
@@ -369,6 +384,7 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotatedMethod_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new MethodAction(), "publicStrNotAnnotated", true);
}
@@ -145,5 +145,108 @@ public class PostbackResultTest extends StrutsInternalTestCase {
}
}
/**
* WW-5623: Verify that HTML special characters in finalLocation are properly
* escaped in the rendered form action attribute to prevent XSS.
*/
public void testFormActionHtmlEscaping() throws Exception {
ActionContext context = ActionContext.getContext();
ValueStack stack = context.getValueStack();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
// Push an object with a malicious property onto the value stack
stack.push(new Object() {
public String getTargetUrl() {
return "/test\"onmouseover=\"alert(1)";
}
});
PostbackResult result = new PostbackResult();
result.setLocation("/redirect?url=${targetUrl}");
result.setPrependServletContext(false);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
expect(mockInvocation.getStack()).andReturn(stack).anyTimes();
control.replay();
result.setActionMapper(container.getInstance(ActionMapper.class));
// Call doExecute directly with a malicious location containing all critical chars
result.doExecute("/test\"onmouseover=\"alert(1)\"&param=<script>", mockInvocation);
String output = res.getContentAsString();
// The action attribute must contain escaped HTML entities
assertTrue("Double quote should be escaped to &quot;",
output.contains("action=\"/test&quot;onmouseover=&quot;alert(1)&quot;&amp;param=&lt;script&gt;\""));
// Must not contain unescaped double-quote that breaks out of the attribute
assertFalse("Raw double-quote must not appear in action value",
output.contains("action=\"/test\""));
control.verify();
}
/**
* WW-5623: Verify that each individual HTML special character is properly escaped.
*/
public void testFormActionEscapesAllHtmlSpecialChars() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
control.replay();
PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/path?a=1&b=2\"<>", mockInvocation);
String output = res.getContentAsString();
assertTrue("Ampersand should be escaped", output.contains("&amp;"));
assertTrue("Double-quote should be escaped", output.contains("&quot;"));
assertTrue("Less-than should be escaped", output.contains("&lt;"));
assertTrue("Greater-than should be escaped", output.contains("&gt;"));
control.verify();
}
/**
* WW-5623: Verify that a clean location (no special chars) renders unchanged.
*/
public void testFormActionCleanLocationUnchanged() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);
IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();
control.replay();
PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/clean/path/action.do", mockInvocation);
String output = res.getContentAsString();
assertTrue("Clean location should render as-is in action attribute",
output.contains("action=\"/clean/path/action.do\""));
control.verify();
}
}
@@ -47,6 +47,7 @@ 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;
@@ -143,6 +144,33 @@ public class ServletRedirectResultTest extends StrutsInternalTestCase implements
assertEquals("/context/bar/foo.jsp", writer.toString());
}
public void testStatusCode200LocationIsHtmlEscapedInBody() {
String maliciousLocation = "/bar/foo.jsp?next=<script>alert(1)</script>&x=1";
String expandedLocation = "/context" + maliciousLocation;
String expectedBody = "/context/bar/foo.jsp?next=&lt;script&gt;alert(1)&lt;/script&gt;&amp;x=1";
view.setLocation(maliciousLocation);
view.setStatusCode(SC_OK);
responseMock.expectAndReturn("encodeRedirectURL", expandedLocation, expandedLocation);
responseMock.expect("setStatus", C.args(C.eq(SC_OK)));
responseMock.expect("setHeader", C.args(C.eq("Location"), C.eq(expandedLocation)));
StringWriter writer = new StringWriter();
responseMock.matchAndReturn("getWriter", new PrintWriter(writer));
try {
view.execute(ai);
requestMock.verify();
responseMock.verify();
} catch (Exception e) {
e.printStackTrace();
fail();
}
assertEquals(expectedBody, writer.toString());
assertFalse("response body must not contain a raw <script> tag",
writer.toString().contains("<script>"));
}
public void testAbsoluteRedirectAnchor() {
view.setLocation("/bar/foo.jsp");
view.setAnchor("fragment");
@@ -0,0 +1,255 @@
/*
* 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;
}
}
}
@@ -0,0 +1,392 @@
# 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.
@@ -0,0 +1,206 @@
# Design: Harden commons-fileupload2 against milestone churn
**Date:** 2026-06-10
**Status:** Approved design — pending implementation plan
**Ticket:** [WW-5632](https://issues.apache.org/jira/browse/WW-5632)
**Origin:** [user@struts mailing list thread](https://lists.apache.org/thread/fcdls8xvd9tp9o6dcog65vkqozv4nq5x)
(Tamás Barta, Struts 7.1.1 file upload `NoSuchMethodError`)
**Related (closed):** [WW-5615](https://issues.apache.org/jira/browse/WW-5615) — "Adapt to renamed
methods in Apache Commons FileUpload 2.0.0-M5", fixed in 7.2.0 via PR #1584 / commit `d2810d42f`.
## Context
A user on Struts 7.1.1 reported `java.lang.NoSuchMethodError:
'void org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload.setSizeMax(long)'`
at upload time. Apache Commons FileUpload 2.0.0-M5 renamed several `AbstractFileUpload` methods
(`setSizeMax``setMaxSize`, `setFileSizeMax``setMaxFileSize`, `setFileCountMax``setMaxFileCount`),
breaking binary compatibility with M4. Struts declared M4 but the user's build resolved M5.
WW-5615 (PR #1584) fixed the **symptom** for 7.2.0: it renamed the three call sites in
`AbstractMultiPartRequest.java` and bumped `commons-fileupload2-jakarta-servlet6` M4 → M5 in
`parent/pom.xml`. That commit did **nothing else**.
This design addresses the **class of failure** that WW-5615 left open.
## Root-cause chain (verified on current `main`, 7.2.0-SNAPSHOT)
1. **Milestone dependency.** Struts depends on `-M` builds of commons-fileupload2, which break
binary compatibility between milestones. Until a 2.0.0 GA exists, Struts is committed to
milestone artifacts.
2. **The volatile API lives in an unmanaged artifact.** `setMaxSize(long)` / `setMaxFileCount(long)`
/ `setMaxFileSize(long)` are declared on `org.apache.commons.fileupload2.core.AbstractFileUpload`
in **`commons-fileupload2-core`** (verified via `javap`). `JakartaServletDiskFileUpload` merely
inherits them. `parent/pom.xml` `<dependencyManagement>` pins only
`commons-fileupload2-jakarta-servlet6`**`commons-fileupload2-core` is unmanaged.** A transitive
dependency pulling a different `-core` milestone reproduces the exact `NoSuchMethodError` even
when `-jakarta-servlet6` is pinned correctly.
3. **The build guardrail is dormant.** `maven-enforcer-plugin` is configured with a
`dependencyConvergence` rule, but **only inside `<pluginManagement>`** of the root `pom.xml`; it is
never bound to an active `<plugins>` section, so it never executes. Struts's own build would not
catch a fileupload version skew.
4. **The BOM does not help consumers.** `struts2-bom` exports only Struts module versions, not the
fileupload version. Downstream apps importing the BOM get no convergence assistance.
Net effect: a downstream/transitive dependency can select a mismatched `commons-fileupload2-core`
milestone, and because milestones break binary compatibility, the user gets a runtime
`NoSuchMethodError` deep in request handling, with no build-time warning.
## Goals
- Make a `commons-fileupload2-core` / `-jakarta-servlet6` version skew **impossible within Struts's
own build**, deterministically.
- Fail the Struts build **early and clearly** if a future transitive dependency wants a
commons-fileupload2 version other than the tested one.
- For downstream consumer runtimes (where Struts's build guards cannot reach), replace the opaque
deep-stack `NoSuchMethodError` with a **clear, actionable `StrutsException`**.
## Non-goals
- **Shading/relocating commons-fileupload2.** Rejected: the library is security-sensitive (CVE
history); shading would force Struts to re-release on every fileupload CVE, against Apache norms,
and bloats the artifact.
- **Exporting the fileupload version through `struts2-bom`.** Considered and deferred — out of scope
for this change.
- **Migrating off milestone versions.** Not actionable until a commons-fileupload2 2.0.0 GA exists.
## Design
### Part A — Build-time fail-fast (POM)
**A1. Manage both artifacts at one version.**
Introduce a single `commons-fileupload2.version` property (single source of truth) and add a
`<dependencyManagement>` entry for `org.apache.commons:commons-fileupload2-core` alongside the
existing `commons-fileupload2-jakarta-servlet6` entry in `parent/pom.xml`, both referencing the
property. Because `<dependencyManagement>` wins Maven version mediation, this forces a single,
matched `-core` version across the entire Struts reactor regardless of transitive requests —
closing root-cause #2 deterministically for Struts's own build.
**A2. Activate a narrowly-scoped enforcer (chosen over global `dependencyConvergence`).**
Bind `maven-enforcer-plugin` into an active `<plugins>` section with a `bannedDependencies` rule
scoped **only** to commons-fileupload2: ban all versions of
`org.apache.commons:commons-fileupload2-core` and
`org.apache.commons:commons-fileupload2-jakarta-servlet6` **except** the pinned
`${commons-fileupload2.version}`. This fails the build immediately if any transitive dependency
introduces a different fileupload version, with effectively zero blast radius on unrelated
dependencies.
> **Why not global `dependencyConvergence`?** It has never actually run; activating it may surface
> many pre-existing, unrelated version conflicts across the multi-module build, ballooning scope
> unpredictably. The fileupload-scoped `bannedDependencies` rule targets exactly the failure mode in
> this report. (Global convergence remains a reasonable separate cleanup task, out of scope here.)
The pinned version string lives once in the `commons-fileupload2.version` property and is referenced
by both the `<dependencyManagement>` entries and the enforcer rule — no duplicated literals.
### Part B — Runtime diagnostics guard
Add a one-time, package-private static check in `AbstractMultiPartRequest`, invoked on first use
(e.g. at the top of `prepareServletFileUpload`), guarded so the reflective probe runs **once** per
JVM — no per-request cost.
**Probe (testable, pure):**
`static void verifyFileUploadApi(Class<?> uploadClass)` reflectively confirms that `uploadClass`
declares (inherited included) `setMaxSize(long)`, `setMaxFileCount(long)`, and `setMaxFileSize(long)`.
If any is absent it throws `org.apache.struts2.StrutsException`.
**Self-maintaining message (no hardcoded "expected" version):** the exception reports the
implementation versions read at runtime from both packages —
`org.apache.commons.fileupload2.core.AbstractFileUpload.class.getPackage().getImplementationVersion()`
(the `-core` version) and `JakartaServletDiskFileUpload.class.getPackage().getImplementationVersion()`
(the `-jakarta-servlet6` version) — names the missing method, and instructs the user to align
`commons-fileupload2-core` with `commons-fileupload2-jakarta-servlet6`. Versions fall back to
`"unknown"` when no manifest implementation version is present. Surfacing the **skew** (core vs
jakarta versions) is the actionable signal; no version constant is baked into Struts to drift.
**One-time guard:** the caller wraps `verifyFileUploadApi(JakartaServletDiskFileUpload.class)` with a
JVM-once gate (`static volatile boolean` or a holder). The probe method itself is stateless so tests
can call it repeatedly.
## Testing & verification
**Part A:**
- `mvn validate -DskipAssembly` runs the enforcer clean on the current tree (no fileupload skew
exists today).
- Manual negative check: temporarily declare a conflicting `commons-fileupload2-core` version and
confirm the build fails with the banned-dependency message; revert.
**Part B (unit tests in `AbstractMultiPartRequestTest`):**
- `verifyFileUploadApi(JakartaServletDiskFileUpload.class)` does **not** throw (real classpath has the
M5 API).
- `verifyFileUploadApi(<stub class lacking the setters>)` throws `StrutsException`; assert the message
names the missing method and the remediation (align `-core` with `-jakarta-servlet6`).
Full suite: `mvn test -DskipAssembly -pl core`.
## Risks
- **Enforcer noise (mitigated).** Scoping `bannedDependencies` to commons-fileupload2 only avoids the
unbounded scope risk of global `dependencyConvergence`.
- **Reflective probe drift.** If a future commons-fileupload2 release renames these setters again, the
probe's hardcoded method names become a deliberate tripwire to update alongside the dependency bump
— acceptable and intended.
- **Null implementation version.** Handled via `"unknown"` fallback so the guard never NPEs while
building its diagnostic message.
## Out of scope / follow-ups
- Tracked under [WW-5632](https://issues.apache.org/jira/browse/WW-5632).
- Global `dependencyConvergence` cleanup across the reactor.
- Exporting third-party versions through `struts2-bom`.
- Revisiting the dependency once commons-fileupload2 2.0.0 GA ships.
## JIRA ticket
**Summary (title):**
```
Harden commons-fileupload2 dependency against milestone binary-incompatibility
```
**Description (JIRA wiki markup — paste into the description field):**
```
h3. Background
[WW-5615|https://issues.apache.org/jira/browse/WW-5615] fixed the {{NoSuchMethodError}}
caused by Apache Commons FileUpload 2.0.0-M5 renaming {{setSizeMax}} -> {{setMaxSize}}
(and friends), shipped in 7.2.0 via [#1584|https://github.com/apache/struts/pull/1584].
That fix addressed the *symptom* for one milestone bump but not the underlying *class of
failure*.
h3. Problem
Struts depends on _milestone_ ({{-M}}) builds of commons-fileupload2, which break binary
compatibility between milestones. Three gaps remain on {{main}}:
* The renamed setters ({{setMaxSize}}, {{setMaxFileCount}}, {{setMaxFileSize}}) live in
*{{commons-fileupload2-core}}* ({{AbstractFileUpload}}), but only
{{commons-fileupload2-jakarta-servlet6}} is pinned in {{dependencyManagement}} —
{{-core}} is unmanaged, so a transitive dependency can pull a mismatched {{-core}}
milestone and reproduce the {{NoSuchMethodError}}.
* The {{maven-enforcer-plugin}} {{dependencyConvergence}} rule sits only in
{{<pluginManagement>}} and is never bound to an active {{<plugins>}} section, so it
never runs — the build cannot catch a fileupload version skew.
* Downstream consumer runtimes get an opaque, deep-stack {{NoSuchMethodError}} with no
actionable guidance.
h3. Proposed changes
* *(A1)* Introduce a single {{commons-fileupload2.version}} property and manage *both*
{{commons-fileupload2-core}} and {{commons-fileupload2-jakarta-servlet6}} at that version
in {{parent/pom.xml}}, forcing a matched {{-core}} version across the reactor.
* *(A2)* Activate {{maven-enforcer-plugin}} with a fileupload-scoped {{bannedDependencies}}
rule that fails the build on any commons-fileupload2 version other than the pinned one
(narrow scope, near-zero blast radius).
* *(B)* Add a once-per-JVM reflective guard in {{AbstractMultiPartRequest}} that throws a
clear {{StrutsException}} reporting the {{-core}} vs {{-jakarta-servlet6}} version skew
instead of an opaque {{NoSuchMethodError}}.
Full design: {{docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md}}
h3. Affects / Fix version
* Affects: 7.1.1+ (root cause present on 7.2.0-SNAPSHOT {{main}})
* Component: File Upload
```
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
<artifactId>struts2-jakarta</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-jakarta</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-velocity-tools-jsp-jakarta</artifactId>
<packaging>jar</packaging>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-jakarta</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-velocity-tools-view-jakarta</artifactId>
<packaging>jar</packaging>
+10 -5
View File
@@ -25,7 +25,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-bom</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</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.3</version>
<version>3.2.4</version>
</dependency>
<!-- Velocity -->
@@ -125,15 +125,20 @@
<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>2.0.0-M5</version>
<version>${commons-fileupload2.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.21.0</version>
<version>2.22.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
@@ -217,7 +222,7 @@
<dependency>
<groupId>org.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>4.21.0</version>
<version>5.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-async-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
+1 -1
View File
@@ -25,7 +25,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-cdi-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-config-browser-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-convention-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-jasperreports-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-jasperreports7-plugin</artifactId>
+1 -1
View File
@@ -25,7 +25,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-javatemplates-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-jfreechart-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-json-plugin</artifactId>
@@ -22,6 +22,7 @@ import org.apache.struts2.action.Action;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.AbstractInterceptor;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.WildcardUtil;
import org.apache.commons.lang3.BooleanUtils;
@@ -72,6 +73,7 @@ public class JSONInterceptor extends AbstractInterceptor {
private String jsonRpcContentType = "application/json-rpc";
private JSONUtil jsonUtil;
private ParameterAuthorizer parameterAuthorizer;
private int maxElements = JSONReader.DEFAULT_MAX_ELEMENTS;
private int maxDepth = JSONReader.DEFAULT_MAX_DEPTH;
private int maxLength = 2_097_152; // 2MB
@@ -131,6 +133,9 @@ public class JSONInterceptor extends AbstractInterceptor {
if (rootObject == null) // model overrides action
rootObject = invocation.getStack().peek();
// enforce @StrutsParameter authorization on JSON body keys
filterUnauthorizedKeys(json, rootObject, invocation.getAction());
// populate fields
populator.populateObject(rootObject, json);
} else {
@@ -200,6 +205,58 @@ public class JSONInterceptor extends AbstractInterceptor {
reader.setMaxKeyLength(maxKeyLength);
}
@SuppressWarnings("rawtypes")
private void filterUnauthorizedKeys(Map json, Object target, Object action) {
filterUnauthorizedKeysRecursive(json, "", target, action);
}
@SuppressWarnings("rawtypes")
private void filterUnauthorizedKeysRecursive(Map json, String prefix, Object target, Object action) {
Iterator<Map.Entry> it = json.entrySet().iterator();
while (it.hasNext()) {
Map.Entry entry = it.next();
if (!(entry.getKey() instanceof String key)) {
// Defensive: a custom JSONReader could produce non-String keys. Skip we cannot
// construct a parameter path for authorization, and JSONPopulator wouldn't bind
// these to bean properties anyway.
LOG.debug("Skipping JSON entry with non-String key [{}] of type [{}] under prefix [{}]",
entry.getKey(), entry.getKey() == null ? "null" : entry.getKey().getClass().getName(), prefix);
continue;
}
String fullPath = prefix.isEmpty() ? key : prefix + "." + key;
if (!parameterAuthorizer.isAuthorized(fullPath, target, action)) {
LOG.warn("JSON body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
fullPath, target.getClass().getName());
it.remove();
continue;
}
// Recurse into nested Maps (JSON objects) to enforce depth-aware authorization
Object value = entry.getValue();
if (value instanceof Map) {
filterUnauthorizedKeysRecursive((Map) value, fullPath, target, action);
} else if (value instanceof java.util.List) {
filterUnauthorizedList((java.util.List) value, fullPath, target, action);
}
}
}
@SuppressWarnings("rawtypes")
private void filterUnauthorizedList(java.util.List list, String prefix, Object target, Object action) {
// Use prefix+"[0]" so that list element properties pick up one extra '[' in their path,
// matching the indexed-path semantics of ParametersInterceptor (e.g. "items[0].key" depth 2).
String elementPrefix = prefix + "[0]";
for (Object item : list) {
if (item instanceof Map) {
filterUnauthorizedKeysRecursive((Map) item, elementPrefix, target, action);
} else if (item instanceof java.util.List) {
// Handle nested lists (e.g. List<List<Map>>) by recursing with the same elementPrefix
filterUnauthorizedList((java.util.List) item, elementPrefix, target, action);
}
}
}
protected String readContentType(HttpServletRequest request) {
String contentType = request.getHeader("Content-Type");
LOG.debug("Content Type from request: {}", contentType);
@@ -585,6 +642,11 @@ public class JSONInterceptor extends AbstractInterceptor {
this.jsonUtil = jsonUtil;
}
@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}
@Inject(value = JSONConstants.JSON_MAX_ELEMENTS, required = false)
public void setMaxElements(String maxElements) {
this.maxElements = Integer.parseInt(maxElements);
@@ -23,6 +23,7 @@ import org.apache.struts2.mock.MockActionInvocation;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.junit.StrutsTestCase;
import org.apache.struts2.junit.util.TestUtils;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
@@ -47,6 +48,8 @@ public class JSONInterceptorTest extends StrutsTestCase {
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
// Default: allow all parameters (simulates requireAnnotations=false)
interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
return interceptor;
}
@@ -556,6 +559,74 @@ public class JSONInterceptorTest extends StrutsTestCase {
}
}
public void testParameterAuthorizerRejectsUnauthorizedKeys() throws Exception {
// JSON body with "foo" and "bar" keys, but authorizer only allows "foo"
this.request.setContent("{\"foo\":\"allowed\", \"bar\":\"blocked\"}".getBytes());
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
JSONUtil jsonUtil = new JSONUtil();
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
// Only authorize "foo", reject "bar"
interceptor.setParameterAuthorizer((parameterName, target, action) -> "foo".equals(parameterName));
TestAction action = new TestAction();
this.invocation.setAction(action);
this.invocation.getStack().push(action);
interceptor.intercept(this.invocation);
// "foo" should be set, "bar" should NOT be set
assertEquals("allowed", action.getFoo());
assertNull(action.getBar());
}
public void testNonStringKeysAreSkippedByAuthorizationFilter() throws Exception {
// Simulate a custom JSON reader producing a Map with a non-String key.
// The authorizer should skip the entry rather than throw ClassCastException.
JSONInterceptor interceptor = new JSONInterceptor();
JSONUtil jsonUtil = new JSONUtil();
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
java.util.Map<Object, Object> mixedKeyMap = new java.util.LinkedHashMap<>();
mixedKeyMap.put("validKey", "ok");
mixedKeyMap.put(42, "shouldBeSkipped"); // Integer key, not String
java.lang.reflect.Method method = JSONInterceptor.class.getDeclaredMethod(
"filterUnauthorizedKeys", java.util.Map.class, Object.class, Object.class);
method.setAccessible(true);
// Should not throw ClassCastException
method.invoke(interceptor, mixedKeyMap, new TestAction(), new TestAction());
// The non-String key entry should still be present (skipped, not removed)
assertTrue("non-String-key entry should remain (skipped, not removed)", mixedKeyMap.containsKey(42));
assertTrue("String-key entry should remain", mixedKeyMap.containsKey("validKey"));
}
public void testParameterAuthorizerAllowsAllWhenPermissive() throws Exception {
// Same JSON body, but authorizer allows all
this.request.setContent("{\"foo\":\"value1\", \"bar\":\"value2\"}".getBytes());
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = createInterceptor();
TestAction action = new TestAction();
this.invocation.setAction(action);
this.invocation.getStack().push(action);
interceptor.intercept(this.invocation);
// Both should be set
assertEquals("value1", action.getFoo());
assertEquals("value2", action.getBar());
}
public void testMaxElementsEnforcedThroughInterceptor() throws Exception {
// JSON object with 5 keys, set maxElements to 3
this.request.setContent("{\"a\":1, \"b\":2, \"c\":3, \"d\":4, \"e\":5}".getBytes());
@@ -575,6 +646,67 @@ public class JSONInterceptorTest extends StrutsTestCase {
}
}
/**
* Tests that nested JSON keys are recursively checked by the parameter authorizer.
* Regression test for lukaszlenart's review: nested @StrutsParameter(depth=N) enforcement.
*/
public void testNestedJsonKeysRecursivelyFiltered() throws Exception {
// JSON body with nested object: {"bean": {"stringField": "test", "intField": 42}}
this.request.setContent("{\"bean\": {\"stringField\": \"test\", \"intField\": 42}}".getBytes());
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
JSONUtil jsonUtil = new JSONUtil();
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
// Authorize "bean" (top-level) and "bean.stringField" (nested) but reject "bean.intField"
interceptor.setParameterAuthorizer((parameterName, target, action) ->
"bean".equals(parameterName) || "bean.stringField".equals(parameterName));
TestAction action = new TestAction();
this.invocation.setAction(action);
this.invocation.getStack().push(action);
interceptor.intercept(this.invocation);
// bean should exist with stringField set, but intField should be default (0)
assertNotNull(action.getBean());
assertEquals("test", action.getBean().getStringField());
assertEquals(0, action.getBean().getIntField());
}
/**
* Tests that when root resolves to a non-action object (not ModelDriven),
* annotation checks are still enforced.
* Regression test for lukaszlenart's review: non-action root bypass.
*/
public void testNonActionRootObjectStillChecked() throws Exception {
this.request.setContent("{\"stringField\":\"injected\", \"intField\":99}".getBytes());
this.request.addHeader("Content-Type", "application/json");
JSONInterceptor interceptor = new JSONInterceptor();
JSONUtil jsonUtil = new JSONUtil();
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
interceptor.setRoot("bean");
// Reject all parameters simulates strict requireAnnotations
interceptor.setParameterAuthorizer((parameterName, target, action) -> false);
TestAction4 action = new TestAction4();
this.invocation.setAction(action);
this.invocation.getStack().push(action);
interceptor.intercept(this.invocation);
// Both fields should remain at defaults since authorizer rejected everything
Bean bean = action.getBean();
assertNotNull(bean);
assertNull(bean.getStringField());
assertEquals(0, bean.getIntField());
}
@Override
protected void setUp() throws Exception {
super.setUp();
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-junit-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-parent</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-rest-plugin</artifactId>
@@ -20,43 +20,362 @@ package org.apache.struts2.rest;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.AbstractInterceptor;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.rest.handler.ContentTypeHandler;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import jakarta.servlet.http.HttpServletRequest;
import java.beans.BeanInfo;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* Uses the content handler to apply the request body to the action
* Uses the content handler to apply the request body to the action.
* <p>
* When {@code struts.parameters.requireAnnotations} is enabled, only properties annotated with
* {@link org.apache.struts2.interceptor.parameter.StrutsParameter} will be populated from the request body,
* consistent with the parameter authorization enforced by
* {@link org.apache.struts2.interceptor.parameter.ParametersInterceptor} for form/query parameters.
*/
public class ContentTypeInterceptor extends AbstractInterceptor {
private static final Logger LOG = LogManager.getLogger(ContentTypeInterceptor.class);
private ContentTypeHandlerManager selector;
private ParameterAuthorizer parameterAuthorizer;
private boolean requireAnnotations = false;
@Inject
public void setContentTypeHandlerSelector(ContentTypeHandlerManager selector) {
this.selector = selector;
}
@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}
@Inject(value = StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS, required = false)
public void setRequireAnnotations(String requireAnnotations) {
this.requireAnnotations = BooleanUtils.toBoolean(requireAnnotations);
}
public String intercept(ActionInvocation invocation) throws Exception {
HttpServletRequest request = ServletActionContext.getRequest();
ContentTypeHandler handler = selector.getHandlerForRequest(request);
Object target = invocation.getAction();
if (target instanceof ModelDriven) {
target = ((ModelDriven<?>)target).getModel();
target = ((ModelDriven<?>) target).getModel();
}
if (request.getContentLength() > 0) {
final String encoding = request.getCharacterEncoding();
InputStream is = request.getInputStream();
InputStreamReader reader = encoding == null ? new InputStreamReader(is) : new InputStreamReader(is, encoding);
handler.toObject(invocation, reader, target);
applyRequestBody(invocation, handler, target, openBodyReader(request));
}
return invocation.invoke();
}
private static InputStreamReader openBodyReader(HttpServletRequest request) throws java.io.IOException {
String encoding = request.getCharacterEncoding();
InputStream is = request.getInputStream();
return encoding == null ? new InputStreamReader(is) : new InputStreamReader(is, encoding);
}
private void applyRequestBody(ActionInvocation invocation, ContentTypeHandler handler, Object target,
InputStreamReader reader) throws Exception {
if (!requireAnnotations) {
// Direct deserialization (backward compat when requireAnnotations is not enabled).
handler.toObject(invocation, reader, target);
return;
}
if (handler instanceof org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler) {
applyWithAuthorizationContext(invocation, handler, target, reader);
} else {
applyTwoPhaseDeserialize(invocation, handler, target, reader);
}
}
/**
* Path used for {@link org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler}s the handler
* authorizes per-property during deserialization, so we only need to bind {@code ParameterAuthorizationContext}
* for the call duration.
*/
private void applyWithAuthorizationContext(ActionInvocation invocation, ContentTypeHandler handler, Object target,
InputStreamReader reader) throws java.io.IOException {
Object action = invocation.getAction();
Object resolvedTarget = parameterAuthorizer.resolveTarget(action);
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext.bind(
parameterAuthorizer, resolvedTarget, action);
try {
handler.toObject(invocation, reader, target);
} finally {
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext.unbind();
}
}
/**
* Legacy two-phase deserialization for handlers that don't authorize themselves: deserialize into a fresh
* instance, then copy only authorized properties. Requires a public no-arg constructor on the target class
* if absent, body processing is rejected entirely (a best-effort scrub cannot guarantee every nested
* unauthorized property is nulled out, so skipping is the safer choice).
*/
private void applyTwoPhaseDeserialize(ActionInvocation invocation, ContentTypeHandler handler, Object target,
InputStreamReader reader) throws Exception {
Object freshInstance = createFreshInstance(target.getClass());
if (freshInstance == null) {
LOG.warn("REST body rejected: requireAnnotations=true but [{}] has no no-arg constructor; "
+ "body deserialization skipped to preserve @StrutsParameter authorization integrity",
target.getClass().getName());
return;
}
handler.toObject(invocation, reader, freshInstance);
copyAuthorizedProperties(freshInstance, target, invocation.getAction(), target, "");
}
private Object createFreshInstance(Class<?> clazz) {
try {
return clazz.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
LOG.debug("Cannot create fresh instance of [{}] via no-arg constructor: {}", clazz.getName(), e.getMessage());
return null;
}
}
/**
* Recursively copies only authorized properties from {@code source} to {@code target},
* enforcing {@code @StrutsParameter} depth semantics for nested object graphs.
*
* <p>{@code authTarget} is always the root action/model passed unchanged through all levels.
* {@code isAuthorized} uses the full dot/bracket path against the root class, so the root
* target must be used not the nested object being visited at the current recursion depth.
*/
private void copyAuthorizedProperties(
Object source, Object target, Object action, Object authTarget, String prefix) throws Exception {
BeanInfo beanInfo = Introspector.getBeanInfo(source.getClass(), Object.class);
for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) {
Method readMethod = pd.getReadMethod();
Method writeMethod = pd.getWriteMethod();
if (readMethod == null || writeMethod == null) {
continue;
}
String fullPath = prefix.isEmpty() ? pd.getName() : prefix + "." + pd.getName();
// Always check against authTarget (root action/model), never the nested object being traversed
if (!parameterAuthorizer.isAuthorized(fullPath, authTarget, action)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
fullPath, authTarget.getClass().getName());
continue;
}
Object sourceValue = readMethod.invoke(source);
if (sourceValue == null) {
// Intentionally skip null values: in two-phase deserialization, properties NOT present in the
// request body will be null in the fresh instance. Copying null would clear pre-initialized
// fields on the target. This is the safer default an explicit JSON null and a missing field
// are indistinguishable after deserialization into a fresh POJO.
continue;
}
if (isNestedBeanType(sourceValue.getClass())) {
// Complex bean: recurse to authorize nested fields, passing authTarget unchanged
Object targetValue = readMethod.invoke(target);
if (targetValue == null) {
Object newTarget = createFreshInstance(sourceValue.getClass());
if (newTarget != null) {
writeMethod.invoke(target, newTarget);
targetValue = newTarget;
} else {
// No no-arg constructor for the nested bean: skip rather than bulk-copy the
// unfiltered source value, which would bypass per-path authorization for every
// property underneath this node.
LOG.warn("REST nested bean [{}] skipped — no no-arg constructor for [{}],"
+ " cannot authorize its nested properties",
fullPath, sourceValue.getClass().getName());
continue;
}
}
copyAuthorizedProperties(sourceValue, targetValue, action, authTarget, fullPath);
} else if (sourceValue instanceof Collection) {
writeMethod.invoke(target,
deepCopyAuthorizedCollection((Collection<?>) sourceValue, fullPath, authTarget, action));
} else if (sourceValue instanceof Map) {
writeMethod.invoke(target,
deepCopyAuthorizedMap((Map<?, ?>) sourceValue, fullPath, authTarget, action));
} else if (sourceValue.getClass().isArray()) {
writeMethod.invoke(target,
deepCopyAuthorizedArray(sourceValue, fullPath, authTarget, action));
} else {
writeMethod.invoke(target, sourceValue);
}
}
}
/**
* Authorizes each complex element of a collection using indexed-path semantics ({@code path[0].field}),
* matching {@code ParametersInterceptor} depth counting. Scalar elements are copied directly.
* Elements whose class has no no-arg constructor are skipped to avoid copying an unfiltered object graph.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private Collection deepCopyAuthorizedCollection(
Collection<?> source, String collectionPath, Object authTarget, Object action) throws Exception {
// Preserve the collection type so that writeMethod.invoke does not fail when the setter
// parameter is typed as Set, SortedSet, etc. Fall back to ArrayList for unrecognised types.
Collection result;
if (source instanceof SortedSet) {
result = new TreeSet(((SortedSet) source).comparator());
} else if (source instanceof Set) {
result = new LinkedHashSet();
} else {
result = new ArrayList();
}
for (Object element : source) {
if (element != null && isNestedBeanType(element.getClass())) {
String elementPath = collectionPath + "[0]";
if (!parameterAuthorizer.isAuthorized(elementPath, authTarget, action)) {
LOG.warn("REST collection element [{}] rejected by @StrutsParameter authorization", elementPath);
continue;
}
Object newElement = createFreshInstance(element.getClass());
if (newElement != null) {
copyAuthorizedProperties(element, newElement, action, authTarget, elementPath);
result.add(newElement);
} else {
// No no-arg constructor: skip element rather than copy an unfiltered object graph
LOG.warn("REST collection element [{}] skipped — no no-arg constructor for [{}]",
elementPath, element.getClass().getName());
}
} else {
result.add(element);
}
}
return result;
}
/**
* Authorizes each complex map value using indexed-path semantics ({@code path[0]}),
* consistent with OGNL bracket notation depth counting. Scalar values are copied directly.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private Map deepCopyAuthorizedMap(
Map<?, ?> source, String mapPath, Object authTarget, Object action) throws Exception {
// Preserve the map type so that writeMethod.invoke does not fail when the setter
// parameter is typed as SortedMap, TreeMap, etc.
Map result;
if (source instanceof SortedMap) {
result = new TreeMap(((SortedMap) source).comparator());
} else {
result = new LinkedHashMap();
}
for (Map.Entry<?, ?> entry : source.entrySet()) {
Object value = entry.getValue();
if (value != null && isNestedBeanType(value.getClass())) {
String valuePath = mapPath + "[0]";
if (!parameterAuthorizer.isAuthorized(valuePath, authTarget, action)) {
LOG.warn("REST map value [{}] rejected by @StrutsParameter authorization", valuePath);
continue;
}
Object newValue = createFreshInstance(value.getClass());
if (newValue != null) {
copyAuthorizedProperties(value, newValue, action, authTarget, valuePath);
result.put(entry.getKey(), newValue);
} else {
LOG.warn("REST map value [{}] skipped — no no-arg constructor for [{}]",
valuePath, value.getClass().getName());
}
} else {
result.put(entry.getKey(), value);
}
}
return result;
}
/**
* Authorizes each complex element of an array ({@code Pojo[]}) using indexed-path semantics,
* matching {@code ParametersInterceptor} depth counting. Scalar elements are copied directly.
*/
private Object deepCopyAuthorizedArray(
Object sourceArray, String arrayPath, Object authTarget, Object action) throws Exception {
int length = Array.getLength(sourceArray);
Class<?> componentType = sourceArray.getClass().getComponentType();
Object result = Array.newInstance(componentType, length);
for (int i = 0; i < length; i++) {
Object element = Array.get(sourceArray, i);
if (element != null && isNestedBeanType(element.getClass())) {
String elementPath = arrayPath + "[0]";
if (!parameterAuthorizer.isAuthorized(elementPath, authTarget, action)) {
LOG.warn("REST array element [{}] rejected by @StrutsParameter authorization", elementPath);
continue;
}
Object newElement = createFreshInstance(element.getClass());
if (newElement != null) {
copyAuthorizedProperties(element, newElement, action, authTarget, elementPath);
Array.set(result, i, newElement);
} else {
LOG.warn("REST array element [{}] skipped — no no-arg constructor for [{}]",
elementPath, element.getClass().getName());
}
} else {
Array.set(result, i, element);
}
}
return result;
}
/**
* Determines whether a class represents a nested bean that should be recursively authorized,
* as opposed to simple/leaf types (primitives, strings, collections, maps, arrays, enums) that
* are handled directly.
*/
private boolean isNestedBeanType(Class<?> clazz) {
if (clazz.isPrimitive() || clazz.isEnum() || clazz.isArray()) {
return false;
}
// Exclude standard library value/leaf types that have no meaningful bean properties to recurse into.
// java.lang.*, java.math.* primitives, String, Number subclasses, etc.
// java.util.* leaf types UUID, Currency, Locale, Date, etc. (NOT Collection/Map which are handled separately)
if (clazz.getName().startsWith("java.lang.") || clazz.getName().startsWith("java.math.")) {
return false;
}
if (clazz.getName().startsWith("java.util.") && !Collection.class.isAssignableFrom(clazz)
&& !Map.class.isAssignableFrom(clazz)) {
return false;
}
if (java.time.temporal.Temporal.class.isAssignableFrom(clazz)) {
return false;
}
if (clazz.getName().startsWith("java.time.")) {
return false;
}
if (clazz.getName().startsWith("java.net.") || clazz.getName().startsWith("java.io.")
|| clazz.getName().startsWith("java.nio.")) {
return false;
}
if (Collection.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz)) {
return false;
}
return true;
}
}
@@ -0,0 +1,46 @@
/*
* 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.rest.handler;
/**
* Marker interface for {@link ContentTypeHandler} implementations that respect the
* {@code ParameterAuthorizationContext} ThreadLocal during deserialization, enforcing
* {@code @StrutsParameter} authorization per-property.
*
* <p>When {@code struts.parameters.requireAnnotations=true}, the REST plugin's
* {@code ContentTypeInterceptor} binds the authorization context before invoking handlers that
* implement this interface, allowing them to filter unauthorized properties during deserialization
* (rather than after, via reflection-based copying).</p>
*
* <p>Handlers that do NOT implement this interface fall back to the legacy two-phase copy in
* {@code ContentTypeInterceptor} correct but more expensive (and requires a no-arg constructor
* on the target).</p>
*
* <p><strong>Implementer responsibility:</strong> A handler that declares this interface MUST register
* the authorization-aware mechanism on its underlying parser (e.g. for Jackson, register
* {@code ParameterAuthorizingModule} on the {@code ObjectMapper}). If the handler implements the
* interface but its parser does not honor the context, authorization will silently do nothing
* a serious security bug. The marker interface is the contract; implementations must uphold it.</p>
*
* @since 7.2.0
*/
public interface AuthorizationAwareContentTypeHandler extends ContentTypeHandler {
// Marker interface no methods. Implementations signal that their toObject() method
// honors ParameterAuthorizationContext for per-property @StrutsParameter enforcement.
}
@@ -32,11 +32,12 @@ import java.io.Writer;
/**
* Handles JSON content using jackson-lib
*/
public class JacksonJsonHandler implements ContentTypeHandler {
public class JacksonJsonHandler implements AuthorizationAwareContentTypeHandler {
private static final String DEFAULT_CONTENT_TYPE = "application/json";
private String defaultEncoding = "ISO-8859-1";
private ObjectMapper mapper = new ObjectMapper();
private ObjectMapper mapper = new ObjectMapper()
.registerModule(new org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
@Override
public void toObject(ActionInvocation invocation, Reader in, Object target) throws IOException {
@@ -31,12 +31,17 @@ import java.io.Writer;
/**
* Handles XML content using Jackson
*/
public class JacksonXmlHandler implements ContentTypeHandler {
public class JacksonXmlHandler implements AuthorizationAwareContentTypeHandler {
private static final Logger LOG = LogManager.getLogger(JacksonXmlHandler.class);
private static final String DEFAULT_CONTENT_TYPE = "application/xml";
private final XmlMapper mapper = new XmlMapper();
private final XmlMapper mapper;
public JacksonXmlHandler() {
mapper = new XmlMapper();
mapper.registerModule(new org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
}
@Override
public void toObject(ActionInvocation invocation, Reader in, Object target) throws IOException {
@@ -27,17 +27,39 @@ import org.apache.juneau.xml.XmlParser;
import org.apache.juneau.xml.XmlSerializer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Map;
/**
* Handles XML content using Apache Juneau
* http://juneau.apache.org/#marshall.html
* <a href="http://juneau.apache.org/#marshall.html">http://juneau.apache.org/#marshall.html</a>
*
* <p>Implements {@link AuthorizationAwareContentTypeHandler}: when
* {@link ParameterAuthorizationContext#isActive()} is {@code true}, performs a post-parse walk
* over the parsed result and copies only authorized properties to the target. Without an active
* context, behavior is unchanged (Juneau parses, then {@code BeanUtils.copyProperties} populates
* the target).</p>
*
* <p>Note: Juneau's parser builds the entire result tree before our authorization walk runs, so
* setter side effects on transient nested objects may fire even for unauthorized properties
* those transient objects are then discarded. This is functionally equivalent to the legacy
* two-phase copy in {@code ContentTypeInterceptor}, with the same security model. Only the
* Jackson-based handlers ({@link JacksonJsonHandler}, {@link JacksonXmlHandler}) achieve the
* stronger guarantee where unauthorized subtrees are never instantiated at all.</p>
*/
public class JuneauXmlHandler implements ContentTypeHandler {
public class JuneauXmlHandler implements AuthorizationAwareContentTypeHandler {
private static final Logger LOG = LogManager.getLogger(JuneauXmlHandler.class);
@@ -51,12 +73,188 @@ public class JuneauXmlHandler implements ContentTypeHandler {
LOG.debug("Converting input into an object of: {}", target.getClass().getName());
try {
Object result = parser.parse(in, target.getClass());
BeanUtils.copyProperties(target, result);
if (ParameterAuthorizationContext.isActive()) {
copyAuthorizedProperties(target, result, "");
} else {
BeanUtils.copyProperties(target, result);
}
} catch (ParseException | IllegalAccessException | InvocationTargetException e) {
throw new IOException(e);
}
}
/**
* Recursively copies properties from {@code source} into {@code target}, consulting
* {@link ParameterAuthorizationContext} at each property. Unauthorized properties are skipped
* (target retains its existing value). Authorized scalar properties are copied directly;
* authorized nested beans are recursed into so their nested fields are individually authorized;
* authorized collections / maps / arrays use indexed-path semantics ({@code path[0].field}).
*/
private void copyAuthorizedProperties(Object target, Object source, String prefix) throws IOException {
if (source == null) {
return;
}
BeanInfo beanInfo;
try {
beanInfo = Introspector.getBeanInfo(source.getClass(), Object.class);
} catch (IntrospectionException e) {
throw new IOException("Unable to introspect " + source.getClass(), e);
}
for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) {
copyAuthorizedProperty(target, source, prefix, pd);
}
}
private void copyAuthorizedProperty(Object target, Object source, String prefix, PropertyDescriptor pd)
throws IOException {
Method readMethod = pd.getReadMethod();
Method writeMethod = pd.getWriteMethod();
if (readMethod == null || writeMethod == null) {
return;
}
String path = prefix.isEmpty() ? pd.getName() : prefix + "." + pd.getName();
if (!ParameterAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
path, target.getClass().getName());
return;
}
Object value;
try {
value = readMethod.invoke(source);
} catch (ReflectiveOperationException e) {
throw new IOException("Failed reading " + path, e);
}
if (value == null) {
return;
}
try {
writeAuthorizedValue(target, readMethod, writeMethod, value, path);
} catch (ReflectiveOperationException e) {
throw new IOException("Failed writing " + path, e);
}
}
private void writeAuthorizedValue(Object target, Method readMethod, Method writeMethod, Object value, String path)
throws ReflectiveOperationException, IOException {
if (value instanceof Collection<?> collection) {
writeMethod.invoke(target, copyAuthorizedCollection(collection, path));
} else if (value instanceof Map<?, ?> map) {
writeMethod.invoke(target, copyAuthorizedMap(map, path));
} else if (value.getClass().isArray()) {
writeMethod.invoke(target, copyAuthorizedArray(value, path));
} else if (isLeaf(value.getClass())) {
writeMethod.invoke(target, value);
} else {
writeAuthorizedNestedBean(target, readMethod, writeMethod, value, path);
}
}
private void writeAuthorizedNestedBean(Object target, Method readMethod, Method writeMethod,
Object value, String path)
throws ReflectiveOperationException, IOException {
Object nestedTarget = readMethod.invoke(target);
if (nestedTarget == null) {
nestedTarget = newInstance(value.getClass());
if (nestedTarget == null) {
// Cannot authorize without a fresh target instance; skip rather than
// bulk-copy the unfiltered value.
LOG.warn("REST nested bean [{}] skipped — no no-arg constructor for [{}]",
path, value.getClass().getName());
return;
}
writeMethod.invoke(target, nestedTarget);
}
copyAuthorizedProperties(nestedTarget, value, path);
}
private Collection<Object> copyAuthorizedCollection(Collection<?> source, String prefix) throws IOException {
Collection<Object> result = newCollection(source);
String elementPath = prefix + "[0]";
for (Object element : source) {
result.add(copyAuthorizedElement(element, elementPath));
}
return result;
}
private Map<Object, Object> copyAuthorizedMap(Map<?, ?> source, String prefix) throws IOException {
Map<Object, Object> result = newMap(source);
String elementPath = prefix + "[0]";
for (Map.Entry<?, ?> entry : source.entrySet()) {
result.put(entry.getKey(), copyAuthorizedElement(entry.getValue(), elementPath));
}
return result;
}
private Object copyAuthorizedArray(Object sourceArray, String prefix) throws IOException {
int length = Array.getLength(sourceArray);
Object result = Array.newInstance(sourceArray.getClass().getComponentType(), length);
String elementPath = prefix + "[0]";
for (int i = 0; i < length; i++) {
Object element = Array.get(sourceArray, i);
Object copied = copyAuthorizedElement(element, elementPath);
if (copied != null || !sourceArray.getClass().getComponentType().isPrimitive()) {
Array.set(result, i, copied);
}
}
return result;
}
private Object copyAuthorizedElement(Object element, String elementPath) throws IOException {
if (element == null || isLeaf(element.getClass())) {
return element;
}
Object freshElement = newInstance(element.getClass());
if (freshElement == null) {
LOG.warn("REST element [{}] skipped — no no-arg constructor for [{}]",
elementPath, element.getClass().getName());
return null;
}
copyAuthorizedProperties(freshElement, element, elementPath);
return freshElement;
}
/**
* Treats common JDK value types as leaves (no introspection needed). Mirrors the
* conservative classification used elsewhere in the REST plugin's two-phase copy.
*/
private static boolean isLeaf(Class<?> c) {
if (c.isPrimitive() || c.isEnum()) return true;
String n = c.getName();
return n.startsWith("java.lang.")
|| n.startsWith("java.math.")
|| n.startsWith("java.time.")
|| n.startsWith("java.net.")
|| n.startsWith("java.io.")
|| n.startsWith("java.nio.")
|| (n.startsWith("java.util.") && !Collection.class.isAssignableFrom(c) && !Map.class.isAssignableFrom(c));
}
private static Object newInstance(Class<?> c) {
try {
return c.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
return null;
}
}
@SuppressWarnings("unchecked")
private static Collection<Object> newCollection(Collection<?> source) {
try {
return source.getClass().getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
return new java.util.ArrayList<>();
}
}
@SuppressWarnings("unchecked")
private static Map<Object, Object> newMap(Map<?, ?> source) {
try {
return source.getClass().getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
return new java.util.LinkedHashMap<>();
}
}
@Override
public String fromObject(ActionInvocation invocation, Object obj, String resultCode, Writer stream) throws IOException {
LOG.debug("Converting an object of {} into string", obj.getClass().getName());
@@ -42,8 +42,17 @@ import java.util.Map;
import java.util.Set;
/**
* Handles XML content
* Handles XML content via the XStream library.
*
* @deprecated since 7.2.0, scheduled for removal in a future major version. XStream has a long
* history of deserialization vulnerabilities and requires per-class allowlist
* maintenance. The default {@code xml} binding in {@code struts-plugin.xml} uses
* {@link JacksonXmlHandler}, which respects {@code @StrutsParameter} authorization
* via the {@link AuthorizationAwareContentTypeHandler} mechanism. Users who have
* explicitly overridden the {@code xml} handler to {@code XStreamHandler} should
* migrate to {@link JacksonXmlHandler}.
*/
@Deprecated(since = "7.2.0", forRemoval = true)
public class XStreamHandler implements ContentTypeHandler {
private static final Logger LOG = LogManager.getLogger(XStreamHandler.class);
@@ -0,0 +1,114 @@
/*
* 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.rest.handler.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
import java.io.IOException;
/**
* A {@link SettableBeanProperty.Delegating} that authorizes each property against the
* {@link ParameterAuthorizationContext} before delegating to the underlying property's
* {@code deserializeAndSet}. Unauthorized properties are silently dropped the JSON value is
* skipped via {@link JsonParser#skipChildren()}, so any nested object graph is never instantiated
* and setter side effects on unauthorized properties never fire.
*
* <p>Path tracking: the wrapper pushes the full path of the current property onto the context's
* path stack before delegating, then pops in a {@code finally} block. For collection / map / array-typed
* properties, the path pushed is suffixed with {@code [0]} so nested element members produce paths like
* {@code items[0].field} matching {@code ParametersInterceptor} depth semantics.</p>
*
* <p>When {@link ParameterAuthorizationContext#isActive()} is {@code false}, this wrapper is a
* straight pass-through to the delegate no overhead for default-config requests.</p>
*
* @since 7.2.0
*/
public class AuthorizingSettableBeanProperty extends SettableBeanProperty.Delegating {
private static final Logger LOG = LogManager.getLogger(AuthorizingSettableBeanProperty.class);
public AuthorizingSettableBeanProperty(SettableBeanProperty delegate) {
super(delegate);
}
@Override
protected SettableBeanProperty withDelegate(SettableBeanProperty d) {
return new AuthorizingSettableBeanProperty(d);
}
@Override
public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object instance) throws IOException {
if (!ParameterAuthorizationContext.isActive()) {
delegate.deserializeAndSet(p, ctxt, instance);
return;
}
String path = ParameterAuthorizationContext.pathFor(getName());
if (!ParameterAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
path, instance.getClass().getName());
p.skipChildren();
return;
}
ParameterAuthorizationContext.pushPath(prefixForNested(path));
try {
delegate.deserializeAndSet(p, ctxt, instance);
} finally {
ParameterAuthorizationContext.popPath();
}
}
@Override
public Object deserializeSetAndReturn(JsonParser p, DeserializationContext ctxt, Object instance) throws IOException {
if (!ParameterAuthorizationContext.isActive()) {
return delegate.deserializeSetAndReturn(p, ctxt, instance);
}
String path = ParameterAuthorizationContext.pathFor(getName());
if (!ParameterAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
path, instance.getClass().getName());
p.skipChildren();
return instance;
}
ParameterAuthorizationContext.pushPath(prefixForNested(path));
try {
return delegate.deserializeSetAndReturn(p, ctxt, instance);
} finally {
ParameterAuthorizationContext.popPath();
}
}
/**
* For Collection / Map / Array properties, the path to push for nested element members is
* {@code path + "[0]"} matching {@code ParametersInterceptor} bracket-depth semantics. Scalar /
* bean properties push the path unchanged.
*/
private String prefixForNested(String pathOfThisProperty) {
JavaType type = getType();
if (type != null && (type.isCollectionLikeType() || type.isMapLikeType() || type.isArrayType())) {
return pathOfThisProperty + "[0]";
}
return pathOfThisProperty;
}
}
@@ -0,0 +1,64 @@
/*
* 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.rest.handler.jackson;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.util.Iterator;
/**
* Jackson {@link SimpleModule} that wraps every {@link SettableBeanProperty} on every bean type
* with an {@link AuthorizingSettableBeanProperty}, enforcing {@code @StrutsParameter} authorization
* during deserialization via the {@link org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext}
* ThreadLocal.
*
* <p>Register this module once on each handler's mapper (e.g. in the constructor). All per-request
* authorization state is read from the ThreadLocal context, so the module + mapper combination is
* thread-safe and reusable across requests.</p>
*
* @since 7.2.0
*/
public class ParameterAuthorizingModule extends SimpleModule {
private static final long serialVersionUID = 1L;
public ParameterAuthorizingModule() {
setDeserializerModifier(new BeanDeserializerModifier() {
@Override
public BeanDeserializerBuilder updateBuilder(DeserializationConfig config,
BeanDescription beanDesc,
BeanDeserializerBuilder builder) {
Iterator<SettableBeanProperty> it = builder.getProperties();
while (it.hasNext()) {
SettableBeanProperty original = it.next();
if (original instanceof AuthorizingSettableBeanProperty) {
continue; // idempotent; protect against double-registration
}
builder.addOrReplaceProperty(new AuthorizingSettableBeanProperty(original), true);
}
return builder;
}
});
}
}
@@ -0,0 +1,206 @@
/*
* 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.rest;
import com.mockobjects.dynamic.AnyConstraintMatcher;
import com.mockobjects.dynamic.Mock;
import junit.framework.TestCase;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.action.Action;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
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.rest.handler.JacksonJsonHandler;
import org.apache.struts2.util.StrutsProxyService;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
/**
* Integration tests for ContentTypeInterceptor that use a real {@link JacksonJsonHandler}
* and a real {@link StrutsParameterAuthorizer}, end-to-end. Verifies that property
* filtering actually occurs on the deserialized object not merely that the wiring runs.
*/
public class ContentTypeInterceptorIntegrationTest extends TestCase {
private ContentTypeInterceptor interceptor;
private SecureRestAction action;
private Mock mockActionInvocation;
private Mock mockSelector;
@Override
protected void setUp() throws Exception {
super.setUp();
action = new SecureRestAction();
setupInterceptorWithAction(action);
}
private void setupInterceptorWithAction(Object actionInstance) {
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>("1000", LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
new StrutsOgnlGuard());
var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
StrutsParameterAuthorizer authorizer = new StrutsParameterAuthorizer();
authorizer.setOgnlUtil(ognlUtil);
authorizer.setProxyService(proxyService);
authorizer.setRequireAnnotations(Boolean.TRUE.toString());
interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer(authorizer);
interceptor.setRequireAnnotations(Boolean.TRUE.toString());
mockActionInvocation = new Mock(ActionInvocation.class);
mockSelector = new Mock(ContentTypeHandlerManager.class);
// ContentTypeInterceptor calls getAction() twice when requireAnnotations=true
mockActionInvocation.expectAndReturn("getAction", actionInstance);
mockActionInvocation.expectAndReturn("getAction", actionInstance);
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
mockSelector.expectAndReturn("getHandlerForRequest", new AnyConstraintMatcher() {
@Override
public boolean matches(Object[] args) { return true; }
}, new JacksonJsonHandler());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager) mockSelector.proxy());
}
private void runWithBody(String body) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(body.getBytes());
request.setContentType("application/json");
ActionContext.of()
.withActionMapping(new ActionMapping())
.withServletRequest(request)
.bind();
interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
mockSelector.verify();
mockActionInvocation.verify();
}
public void testAnnotatedTopLevelPropertyIsApplied() throws Exception {
runWithBody("{\"name\":\"alice\"}");
assertEquals("alice", action.getName());
}
public void testUnannotatedTopLevelPropertyIsRejected() throws Exception {
runWithBody("{\"role\":\"admin\"}");
assertNull("unannotated 'role' must not be set", action.getRole());
}
public void testMixedPropertiesFilteredCorrectly() throws Exception {
runWithBody("{\"name\":\"alice\",\"role\":\"admin\"}");
assertEquals("alice", action.getName());
assertNull(action.getRole());
}
public void testNestedPropertyAuthorizedWhenDepthAllows() throws Exception {
runWithBody("{\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}");
assertNotNull("address should be set", action.getAddress());
assertEquals("Warsaw", action.getAddress().getCity());
assertEquals("00-001", action.getAddress().getZip());
}
public void testNestedPropertyRejectedWhenDepthInsufficient() throws Exception {
// shallowAddress has @StrutsParameter on the setter (depth-0 authorized) but the getter
// has no depth>=1 annotation. The Jackson path enters shallowAddress (constructed by
// Jackson) but skipChildren on each inner property so the Address is non-null but its
// city/zip fields stay null.
runWithBody("{\"shallowAddress\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}");
assertNotNull("shallowAddress is depth-0 authorized; Jackson constructs it",
action.getShallowAddress());
assertNull("nested city must be rejected (depth-1 not authorized)",
action.getShallowAddress().getCity());
assertNull("nested zip must be rejected (depth-1 not authorized)",
action.getShallowAddress().getZip());
}
// --- Tests proving the new Jackson authorization path is in use ---
public void testJacksonHandlerDoesNotRequireNoArgConstructor() throws Exception {
// The legacy two-phase copy required a no-arg constructor on the target. Jackson's
// readerForUpdating populates the existing instance directly, so this constraint
// is gone proof that the new AuthorizationAware path is being taken.
NoNoArgAction noNoArg = new NoNoArgAction("preserved-pre-deserialization-value");
setupInterceptorWithAction(noNoArg);
runWithBody("{\"name\":\"alice\"}");
assertEquals("alice", noNoArg.getName());
assertEquals("pre-existing field must be preserved (no fresh-instance copy)",
"preserved-pre-deserialization-value", noNoArg.getRequiredField());
}
public void testRejectedAtParentNeverInstantiatesNestedObject() throws Exception {
// Stronger guarantee than the two-phase copy: when the parent property is rejected,
// Jackson's skipChildren() discards the entire JSON subtree and the nested object
// is never constructed. role-typed fixture: address requires a setter @StrutsParameter
// for depth-0 authorization. By giving address a fresh action where address is depth-0
// unauthorized, we prove the setter is never called and address stays null.
// (We use a custom action where address has no setter annotation.)
UnauthorizedNestedAction restrictedAction = new UnauthorizedNestedAction();
setupInterceptorWithAction(restrictedAction);
runWithBody("{\"unauthorized\":{\"city\":\"Warsaw\"}}");
assertNull("unauthorized property must be rejected at parent — Jackson never enters",
restrictedAction.getUnauthorized());
}
// --- Test fixtures for new path verification ---
/**
* Action with no public no-arg constructor would fail the legacy two-phase copy's
* createFreshInstance check, but works fine with the Jackson authorization path.
*/
public static class NoNoArgAction extends ActionSupport {
private final String requiredField;
private String name;
public NoNoArgAction(String requiredField) {
this.requiredField = requiredField;
}
public String getName() { return name; }
@StrutsParameter
public void setName(String name) { this.name = name; }
public String getRequiredField() { return requiredField; }
}
/**
* Action with a property that has NO @StrutsParameter on its setter depth-0 authorization
* fails, so Jackson must never enter this property nor instantiate the nested object.
*/
public static class UnauthorizedNestedAction extends ActionSupport {
private SecureRestAction.Address unauthorized;
public SecureRestAction.Address getUnauthorized() { return unauthorized; }
// No @StrutsParameter annotation depth-0 path "unauthorized" is rejected.
public void setUnauthorized(SecureRestAction.Address unauthorized) {
this.unauthorized = unauthorized;
}
}
}
@@ -32,12 +32,14 @@ import java.nio.charset.StandardCharsets;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.rest.handler.ContentTypeHandler;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.springframework.mock.web.MockHttpServletRequest;
public class ContentTypeInterceptorTest extends TestCase {
public void testRequestWithoutEncoding() throws Exception {
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
ActionSupport action = new ActionSupport();
@@ -76,6 +78,7 @@ public class ContentTypeInterceptorTest extends TestCase {
final Charset charset = StandardCharsets.US_ASCII;
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
ActionSupport action = new ActionSupport();
@@ -116,6 +119,7 @@ public class ContentTypeInterceptorTest extends TestCase {
final Charset charset = StandardCharsets.UTF_8;
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
ActionSupport action = new ActionSupport();
@@ -151,4 +155,82 @@ public class ContentTypeInterceptorTest extends TestCase {
mockActionInvocation.verify();
mockContentTypeHandler.verify();
}
public void testRequireAnnotationsEnabled_twoPhaseDeserialization() throws Exception {
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer((parameterName, target, action) -> false);
interceptor.setRequireAnnotations(Boolean.TRUE.toString());
ActionSupport action = new ActionSupport();
Mock mockActionInvocation = new Mock(ActionInvocation.class);
Mock mockContentTypeHandler = new Mock(ContentTypeHandler.class);
mockContentTypeHandler.expect("toObject", new AnyConstraintMatcher() {
public boolean matches(Object[] args) {
return true;
}
});
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("getAction", action);
Mock mockContentTypeHandlerManager = new Mock(ContentTypeHandlerManager.class);
mockContentTypeHandlerManager.expectAndReturn("getHandlerForRequest", new AnyConstraintMatcher() {
public boolean matches(Object[] args) {
return true;
}
}, mockContentTypeHandler.proxy());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager) mockContentTypeHandlerManager.proxy());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(new byte[] {1});
ActionContext.of()
.withActionMapping(new ActionMapping())
.withServletRequest(request)
.bind();
interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
mockContentTypeHandlerManager.verify();
mockActionInvocation.verify();
mockContentTypeHandler.verify();
}
public void testRequireAnnotationsEnabled_selectiveFilter() throws Exception {
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer((parameterName, target, action) -> "name".equals(parameterName));
interceptor.setRequireAnnotations(Boolean.TRUE.toString());
ActionSupport action = new ActionSupport();
Mock mockActionInvocation = new Mock(ActionInvocation.class);
Mock mockContentTypeHandler = new Mock(ContentTypeHandler.class);
mockContentTypeHandler.expect("toObject", new AnyConstraintMatcher() {
public boolean matches(Object[] args) {
return true;
}
});
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("getAction", action);
Mock mockContentTypeHandlerManager = new Mock(ContentTypeHandlerManager.class);
mockContentTypeHandlerManager.expectAndReturn("getHandlerForRequest", new AnyConstraintMatcher() {
public boolean matches(Object[] args) {
return true;
}
}, mockContentTypeHandler.proxy());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager) mockContentTypeHandlerManager.proxy());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(new byte[] {1});
ActionContext.of()
.withActionMapping(new ActionMapping())
.withServletRequest(request)
.bind();
interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
mockContentTypeHandlerManager.verify();
mockActionInvocation.verify();
mockContentTypeHandler.verify();
}
}
@@ -0,0 +1,178 @@
/*
* 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.rest;
import com.mockobjects.dynamic.AnyConstraintMatcher;
import com.mockobjects.dynamic.Mock;
import junit.framework.TestCase;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.action.Action;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
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.rest.handler.JuneauXmlHandler;
import org.apache.struts2.util.StrutsProxyService;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
/**
* End-to-end integration tests for {@link JuneauXmlHandler} as an
* {@link org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler}: a real
* Juneau parser + a real {@link StrutsParameterAuthorizer} + the {@link ContentTypeInterceptor}
* binding {@code ParameterAuthorizationContext} before invoking the handler. Verifies that
* authorization filtering happens during the handler's post-parse walk rather than via the
* legacy two-phase copy in the interceptor.
*/
public class JuneauXmlHandlerIntegrationTest extends TestCase {
private ContentTypeInterceptor interceptor;
private SecureRestAction action;
private Mock mockActionInvocation;
private Mock mockSelector;
@Override
protected void setUp() throws Exception {
super.setUp();
action = new SecureRestAction();
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>("1000", LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
new StrutsOgnlGuard());
var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
StrutsParameterAuthorizer authorizer = new StrutsParameterAuthorizer();
authorizer.setOgnlUtil(ognlUtil);
authorizer.setProxyService(proxyService);
authorizer.setRequireAnnotations(Boolean.TRUE.toString());
interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer(authorizer);
interceptor.setRequireAnnotations(Boolean.TRUE.toString());
mockActionInvocation = new Mock(ActionInvocation.class);
mockSelector = new Mock(ContentTypeHandlerManager.class);
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
mockActionInvocation.matchAndReturn("getInvocationContext", ActionContext.getContext());
mockSelector.expectAndReturn("getHandlerForRequest", new AnyConstraintMatcher() {
@Override
public boolean matches(Object[] args) { return true; }
}, new JuneauXmlHandler());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager) mockSelector.proxy());
}
private void runWithBody(String body) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(body.getBytes());
request.setContentType("application/xml");
ActionContext.of()
.withActionMapping(new ActionMapping())
.withServletRequest(request)
.bind();
interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
mockSelector.verify();
mockActionInvocation.verify();
}
public void testAnnotatedTopLevelPropertyIsApplied() throws Exception {
runWithBody("<object><name>alice</name></object>");
assertEquals("alice", action.getName());
}
public void testUnannotatedTopLevelPropertyIsRejected() throws Exception {
runWithBody("<object><role>admin</role></object>");
assertNull("unannotated 'role' must not be set on target", action.getRole());
}
public void testMixedPropertiesFilteredCorrectly() throws Exception {
runWithBody("<object><name>alice</name><role>admin</role></object>");
assertEquals("alice", action.getName());
assertNull(action.getRole());
}
public void testNestedPropertyAuthorizedWhenDepthAllows() throws Exception {
runWithBody("<object><address><city>Warsaw</city><zip>00-001</zip></address></object>");
assertNotNull("address should be set", action.getAddress());
assertEquals("Warsaw", action.getAddress().getCity());
assertEquals("00-001", action.getAddress().getZip());
}
public void testNestedPropertyRejectedWhenDepthInsufficient() throws Exception {
// shallowAddress: setter @StrutsParameter (depth-0 authorized) but getter has no depth>=1
// annotation. The handler walks into the parsed Address and rejects city/zip individually.
runWithBody("<object><shallowAddress><city>Warsaw</city><zip>00-001</zip></shallowAddress></object>");
assertNotNull("shallowAddress is depth-0 authorized; handler enters it",
action.getShallowAddress());
assertNull("nested city must be rejected (depth-1 not authorized)",
action.getShallowAddress().getCity());
assertNull("nested zip must be rejected (depth-1 not authorized)",
action.getShallowAddress().getZip());
}
public void testCollectionOfScalarsCopiedThroughAuthorizedWalk() throws Exception {
runWithBody("<object><tags><string>red</string><string>green</string></tags></object>");
assertNotNull(action.getTags());
assertEquals(2, action.getTags().size());
assertEquals("red", action.getTags().get(0));
assertEquals("green", action.getTags().get(1));
}
public void testCollectionOfBeansCopiedThroughAuthorizedWalk() throws Exception {
runWithBody("<object><addresses>"
+ "<object><city>Warsaw</city><zip>00-001</zip></object>"
+ "<object><city>Krakow</city><zip>30-001</zip></object>"
+ "</addresses></object>");
assertNotNull(action.getAddresses());
assertEquals(2, action.getAddresses().size());
assertEquals("Warsaw", action.getAddresses().get(0).getCity());
assertEquals("00-001", action.getAddresses().get(0).getZip());
assertEquals("Krakow", action.getAddresses().get(1).getCity());
}
public void testMapOfScalarsCopiedThroughAuthorizedWalk() throws Exception {
runWithBody("<object><attributes><color>red</color><size>large</size></attributes></object>");
assertNotNull(action.getAttributes());
assertEquals("red", action.getAttributes().get("color"));
assertEquals("large", action.getAttributes().get("size"));
}
public void testArrayOfScalarsCopiedThroughAuthorizedWalk() throws Exception {
runWithBody("<object><aliases><string>al1</string><string>al2</string></aliases></object>");
assertNotNull(action.getAliases());
assertEquals(2, action.getAliases().length);
assertEquals("al1", action.getAliases()[0]);
assertEquals("al2", action.getAliases()[1]);
}
public void testEmptyCollectionPreserved() throws Exception {
runWithBody("<object><tags></tags></object>");
assertNotNull(action.getTags());
assertEquals(0, action.getTags().size());
}
}
@@ -0,0 +1,101 @@
/*
* 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.rest;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
import java.util.List;
import java.util.Map;
/**
* Test fixture for ContentTypeInterceptor integration tests.
* Has annotated and unannotated properties to exercise authorization filtering.
*/
public class SecureRestAction extends ActionSupport {
private String name;
private String role;
private Address address;
private Address shallowAddress;
private List<String> tags;
private List<Address> addresses;
private Map<String, String> attributes;
private String[] aliases;
public String getName() { return name; }
@StrutsParameter
public void setName(String name) { this.name = name; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
// Both annotations needed for REST: setter authorizes the top-level "address" (depth 0),
// getter(depth=1) authorizes nested "address.city" (depth 1). Note: ParametersInterceptor
// only requires the getter annotation REST's recursive copy authorizes each path level
// independently. This divergence is tracked for the Approach C refactor.
@StrutsParameter(depth = 1)
public Address getAddress() { return address; }
@StrutsParameter
public void setAddress(Address address) { this.address = address; }
// shallowAddress: depth-0 authorized (setter annotated), but nested fields rejected
// because the getter has no depth>=1 annotation.
public Address getShallowAddress() { return shallowAddress; }
@StrutsParameter
public void setShallowAddress(Address shallowAddress) { this.shallowAddress = shallowAddress; }
@StrutsParameter(depth = 1)
public List<String> getTags() { return tags; }
@StrutsParameter
public void setTags(List<String> tags) { this.tags = tags; }
@StrutsParameter(depth = 2)
public List<Address> getAddresses() { return addresses; }
@StrutsParameter
public void setAddresses(List<Address> addresses) { this.addresses = addresses; }
@StrutsParameter(depth = 1)
public Map<String, String> getAttributes() { return attributes; }
@StrutsParameter
public void setAttributes(Map<String, String> attributes) { this.attributes = attributes; }
@StrutsParameter(depth = 1)
public String[] getAliases() { return aliases; }
@StrutsParameter
public void setAliases(String[] aliases) { this.aliases = aliases; }
public static class Address {
private String city;
private String zip;
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getZip() { return zip; }
public void setZip(String zip) { this.zip = zip; }
}
}
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class JuneauXmlHandlerTest extends XWorkTestCase {
@@ -94,4 +95,12 @@ public class JuneauXmlHandlerTest extends XWorkTestCase {
.containsExactly("Adam", "Ewa");
}
public void testMalformedXmlIsWrappedInIOException() {
SimpleBean obj = new SimpleBean();
Reader in = new StringReader("<object><name>unterminated");
assertThatThrownBy(() -> handler.toObject(ai, in, obj))
.isInstanceOf(java.io.IOException.class);
}
}
@@ -0,0 +1,175 @@
/*
* 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.rest.handler.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import junit.framework.TestCase;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
public class ParameterAuthorizingModuleTest extends TestCase {
private ObjectMapper mapper;
@Override
protected void setUp() {
mapper = new ObjectMapper().registerModule(new ParameterAuthorizingModule());
}
@Override
protected void tearDown() {
ParameterAuthorizationContext.unbind();
}
private void bind(ParameterAuthorizer authorizer, Object instance) {
ParameterAuthorizationContext.bind(authorizer, instance, instance);
}
public void testNoContext_passThrough() throws Exception {
// No bind wrapper is a no-op
Person p = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", Person.class);
assertEquals("alice", p.name);
assertEquals("admin", p.role);
}
public void testTopLevelAuthorized() throws Exception {
bind((path, t, a) -> "name".equals(path), new Person());
Person result = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", Person.class);
assertEquals("alice", result.name);
assertNull(result.role);
}
public void testNestedPropertyAuthorizedByPath() throws Exception {
bind((path, t, a) -> "address".equals(path) || "address.city".equals(path), new Person());
Person result = mapper.readValue(
"{\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}", Person.class);
assertNotNull(result.address);
assertEquals("Warsaw", result.address.city);
assertNull(result.address.zip);
}
public void testNestedRejectedAtParent() throws Exception {
bind((path, t, a) -> "name".equals(path), new Person());
Person result = mapper.readValue(
"{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\"}}", Person.class);
assertEquals("alice", result.name);
assertNull(result.address);
}
public void testListUsesIndexedPath() throws Exception {
bind((path, t, a) -> "addresses".equals(path) || "addresses[0].city".equals(path), new Person());
Person result = mapper.readValue(
"{\"addresses\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}", Person.class);
assertEquals(1, result.addresses.size());
assertEquals("Warsaw", result.addresses.get(0).city);
assertNull(result.addresses.get(0).zip);
}
public void testArrayUsesIndexedPath() throws Exception {
bind((path, t, a) -> "addressArray".equals(path) || "addressArray[0].city".equals(path), new Person());
Person result = mapper.readValue(
"{\"addressArray\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}", Person.class);
assertEquals(1, result.addressArray.length);
assertEquals("Warsaw", result.addressArray[0].city);
assertNull(result.addressArray[0].zip);
}
public void testMapUsesIndexedPath() throws Exception {
bind((path, t, a) -> "addressMap".equals(path) || "addressMap[0].city".equals(path), new Person());
Person result = mapper.readValue(
"{\"addressMap\":{\"home\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}}", Person.class);
assertNotNull(result.addressMap.get("home"));
assertEquals("Warsaw", result.addressMap.get("home").city);
assertNull(result.addressMap.get("home").zip);
}
public void testPathStackCleanAfterDeserialization() throws Exception {
bind((path, t, a) -> true, new Person());
mapper.readValue("{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\"}}", Person.class);
assertEquals("path stack must be empty after deserialization", "",
ParameterAuthorizationContext.currentPathPrefix());
}
public void testBuilderDeserializationNoContextPassThrough() throws Exception {
// No bind AuthorizingSettableBeanProperty.deserializeSetAndReturn falls through
// to the delegate without consulting the authorization context.
ImmutablePerson p = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", ImmutablePerson.class);
assertEquals("alice", p.name);
assertEquals("admin", p.role);
}
public void testBuilderDeserializationAuthorizedTopLevel() throws Exception {
bind((path, t, a) -> "name".equals(path), new ImmutablePerson.Builder());
ImmutablePerson p = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", ImmutablePerson.class);
assertEquals("alice", p.name);
assertNull("unauthorized property must be skipped on builder path", p.role);
}
public void testBuilderDeserializationRejectsAllProperties() throws Exception {
bind((path, t, a) -> false, new ImmutablePerson.Builder());
ImmutablePerson p = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", ImmutablePerson.class);
assertNull(p.name);
assertNull(p.role);
}
// --- Fixtures ---
public static class Person {
public String name;
public String role;
public Address address;
public java.util.List<Address> addresses;
public Address[] addressArray;
public java.util.Map<String, Address> addressMap;
}
public static class Address {
public String city;
public String zip;
}
/**
* Builder-pattern fixture: forces Jackson to use {@code BuilderBasedDeserializer},
* which dispatches property deserialization through {@code SettableBeanProperty.deserializeSetAndReturn}
* the alternate code path on {@code AuthorizingSettableBeanProperty} not exercised by
* setter-based fixtures like {@link Person}.
*/
@JsonDeserialize(builder = ImmutablePerson.Builder.class)
public static final class ImmutablePerson {
public final String name;
public final String role;
private ImmutablePerson(Builder b) {
this.name = b.name;
this.role = b.role;
}
@JsonPOJOBuilder(withPrefix = "set")
public static class Builder {
private String name;
private String role;
public Builder setName(String n) { this.name = n; return this; }
public Builder setRole(String r) { this.role = r; return this; }
public ImmutablePerson build() { return new ImmutablePerson(this); }
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-spring-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-testng-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-tiles-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-velocity-plugin</artifactId>
+1 -1
View File
@@ -24,7 +24,7 @@
<parent>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-plugins</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
</parent>
<artifactId>struts2-xslt-plugin</artifactId>
+32 -15
View File
@@ -30,7 +30,7 @@
<modelVersion>4.0.0</modelVersion>
<artifactId>struts2-project</artifactId>
<version>7.2.0-SNAPSHOT</version>
<version>7.2.0</version>
<packaging>pom</packaging>
<name>Struts 2</name>
<url>https://struts.apache.org/</url>
@@ -52,7 +52,7 @@
<connection>scm:git:https://gitbox.apache.org/repos/asf/struts.git</connection>
<developerConnection>scm:git:https://gitbox.apache.org/repos/asf/struts.git</developerConnection>
<url>https://github.com/apache/struts/</url>
<tag>HEAD</tag>
<tag>STRUTS_7_2_0</tag>
</scm>
<issueManagement>
@@ -106,25 +106,26 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-02-01T12:20:24Z</project.build.outputTimestamp>
<project.build.outputTimestamp>2026-06-14T17:30:40Z</project.build.outputTimestamp>
<maven.compiler.release>17</maven.compiler.release>
<!-- Maven plugin -->
<maven-javadoc-plugin.version>3.12.0</maven-javadoc-plugin.version>
<maven-surefire-plugin.version>3.5.5</maven-surefire-plugin.version>
<maven-surefire-plugin.version>3.5.6</maven-surefire-plugin.version>
<!-- dependency versions in alphanumeric order -->
<asm.version>9.9.1</asm.version>
<asm.version>9.10.1</asm.version>
<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>
<hibernate-validator.version>8.0.2.Final</hibernate-validator.version>
<jackson.version>2.21.2</jackson.version>
<jackson.version>2.22.0</jackson.version>
<jakarta-ee.version>10.0.0</jakarta-ee.version>
<jaxb-impl.version>4.0.7</jaxb-impl.version>
<log4j2.version>2.25.4</log4j2.version>
<jaxb-impl.version>4.0.8</jaxb-impl.version>
<log4j2.version>2.26.0</log4j2.version>
<mockito.version>5.23.0</mockito.version>
<ognl.version>3.4.10</ognl.version>
<slf4j.version>2.0.17</slf4j.version>
<ognl.version>3.4.11</ognl.version>
<slf4j.version>2.0.18</slf4j.version>
<spring.version>6.2.12</spring.version>
<struts-annotations.version>2.0</struts-annotations.version>
<velocity-tools.version>3.1</velocity-tools.version>
@@ -267,7 +268,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.10.0</version>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -296,7 +297,9 @@
<exclude>CLAUDE.md</exclude>
<exclude>CODEOWNERS</exclude>
<exclude>SECURITY.md</exclude>
<exclude>AGENTS.md</exclude>
<exclude>**/README.md</exclude>
<exclude>docs/**/*.md</exclude>
<exclude>src/main/resources/*-LICENSE.txt</exclude>
<exclude>src/site/resources/tags/**/*.html</exclude>
<exclude>src/test/resources/**/*.txt</exclude>
@@ -320,7 +323,7 @@
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>12.2.0</version>
<version>12.2.2</version>
<configuration>
<suppressionFiles>
<suppressionFile>src/etc/project-suppression.xml</suppressionFile>
@@ -340,13 +343,23 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.6.2</version>
<version>3.6.3</version>
<executions>
<execution>
<id>enforce</id>
<configuration>
<rules>
<dependencyConvergence />
<bannedDependencies>
<message>commons-fileupload2 version skew detected: only ${commons-fileupload2.version} is allowed. Align all commons-fileupload2 artifacts (core and jakarta-servlet6) to the version defined by the commons-fileupload2.version property in the root POM.</message>
<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>
<goals>
@@ -363,7 +376,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>3.21.0</version>
<version>3.22.0</version>
</plugin>
</plugins>
</pluginManagement>
@@ -374,6 +387,10 @@
<artifactId>maven-release-plugin</artifactId>
<version>3.3.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<configuration>