Skip to content

WW-5675 Share parsed OGNL security configuration across SecurityMemberAccess instances - #1847

Open
lukaszlenart wants to merge 18 commits into
mainfrom
WW-5675-share-parsed-ognl-security-config
Open

WW-5675 Share parsed OGNL security configuration across SecurityMemberAccess instances#1847
lukaszlenart wants to merge 18 commits into
mainfrom
WW-5675-share-parsed-ognl-security-config

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Aug 14, 2026

Copy link
Copy Markdown
Member

Fixes WW-5675 — sub-task of WW-5667.

The problem

SecurityMemberAccess is a Scope.PROTOTYPE bean, so a fresh instance is constructed once per value stack and again for every OGNL context — several times per request. Each construction re-ran all sixteen @Inject configuration setters, each re-parsing a raw comma-delimited string from scratch: comma splitting, strip, classloader validation, Pattern.compile, and HashSet accumulation. With the stock struts-excluded-classes.xml that is roughly 90 configuration entries rebuilt per instantiation.

OgnlUtil.copy alone calls createDefaultContext twice, so a single copy paid for two full configuration rebuilds.

This is the dominant half of WW-5667. The sibling ticket WW-5674 (merged as 81b34c295) addressed the per-access allocations; this is the one expected to move the reporter's 9%.

Note that the fix originally proposed on WW-5667 — caching the parsed set in a SecurityMemberAccess field — cannot work, because the instance holding that field is itself discarded and rebuilt each time.

The change

A new container-singleton bean, SecurityMemberAccessConfig, owns all configuration parsing and does it once per container. SecurityMemberAccess stays Scope.PROTOTYPE and receives that bean through a single @Inject setter, copying immutable set references — no parsing, no HashSet construction, no Pattern.compile per instantiation.

Also in this PR:

  • The lazy dev-mode flip (useDevModeConfiguration() plus a volatile guard) is gone from the OGNL access path. Dev-mode now resolves once, in the config bean's Initializable.init().
  • The two-set allowlist walk added by WW-5674 collapses into a single precomputed union, resolving WW-5678's first item.
  • ConfigParseUtil.validatePackageNames no longer recompiles Pattern.compile("\\s") once per package name (~58 recompiles per instantiation under the default config).

Why setter injection rather than constructor injection

Deliberate, and load-bearing. ContainerImpl.addInjectors recurses into superclasses first, so an existing user subclass calling super(providerAllowlist, threadAllowlist) keeps compiling and still receives the configuration. Had the configuration arrived via the constructor with a deprecated two-argument overload retained, such a subclass would have compiled cleanly and run with empty exclusions — a silent fail-open. Setter injection removes that failure mode rather than documenting it.

useConfig is a mandatory @Inject, so a container missing the binding throws at build time instead of running with weaker exclusions.

Registration in two places

SecurityMemberAccessConfig is registered in both DefaultConfiguration.bootstrapFactories and struts-beans.xml, mirroring ProviderAllowlist and ThreadAllowlist. Both are load-bearing:

  • Dispatcher.init() installs its own provider list and never adds StrutsDefaultConfigurationProvider, so the main container is built from StrutsBeanSelectionProvider plus struts-beans.xml.
  • DefaultConfiguration.reloadContainer builds a bootstrap container from bootstrapFactories and instantiates SecurityMemberAccess through it before the main container exists.

Production throws at startup without either.

⚠️ Breaking change in a minor release

Five dev-mode setters are deleted from SecurityMemberAccess — four public, one protected:

Method Visibility Bound to
useDevModeExcludedClasses public struts.devMode.excludedClasses
useDevModeExcludedPackageNamePatterns public struts.devMode.excludedPackageNamePatterns
useDevModeExcludedPackageNames public struts.devMode.excludedPackageNames
useDevModeExcludedPackageExemptClasses public struts.devMode.excludedPackageExemptClasses
useDevMode protected struts.devMode

useDevMode being protected means its removal is visible only to subclasses. The private useDevModeConfiguration() and the six dev-mode fields go with them, but those were never part of the API.

None of the underlying struts.devMode.* constants change, and dev-mode configuration continues to work exactly as before — it is now read once per container by SecurityMemberAccessConfig instead of once per SecurityMemberAccess instance.

They are only ever container-injected — a repo-wide search finds no direct caller in core, plugins, apps, or tests. Retaining them faithfully would mean keeping isDevMode plus the four dev-mode set fields on the instance and reinstating the lazy flip, i.e. keeping precisely the code this change exists to delete. Retaining them in simplified form was rejected because the current semantics are subtle enough that any simplification would silently change them: a manual useDevModeExcludedClasses call accumulates into the dev-mode set, which then replaces — rather than unions with — excludedClasses on first access.

The failure mode for anyone affected is a compile error on upgrade, which is loud and has an obvious fix.

This needs a Version Notes and Migration Guide entry for the release. That entry does not exist yet.

The other eleven configuration setters are not removed. They keep their exact bodies and still mutate the instance, so the ~110 existing direct call sites are unaffected. They are annotated @Deprecated(since = "7.4.0", forRemoval = true) and scheduled for removal in WW-5682 (8.0.0).

Behaviour

OGNL allow/deny semantics are unchanged. One visible difference: the "DevMode enabled, using DevMode excluded classes and packages..." warning now fires when the configuration singleton is built rather than on the first OGNL access — a deterministic startup signal instead of one contingent on traffic.

Testing

Full core suite: 3181 tests, 0 failures, 0 errors. plugins/spring 61/61, plugins/cdi 17/17.

New coverage, beyond the existing suites passing untouched:

  • Sharing proof — several SecurityMemberAccess instances from one container hold reference-identical configuration sets (assertSame, since any re-parse necessarily allocates fresh). The container is loaded with every relevant constant set away from its default, so the assertions cannot pass on shared defaults.
  • Instance isolation — a deprecated setter call on one instance perturbs neither a sibling nor the singleton.
  • Subclass injection — a subclass using the two-argument constructor still receives the configuration, guarding the fail-open hole described above.
  • Production registration — a StrutsInternalTestCase boot (real Dispatcher.init(), loads struts-beans.xml) asserts the bean is a singleton on the path production actually uses. Verified by temporarily setting scope="prototype" and confirming the test fails.
  • Differential parsing — the config bean's output is compared against a frozen copy of the accumulation logic it replaces, including the useAllowStaticFieldAccessuseExcludedClasses side effect and the commutativity that makes the setters safe against the container's unspecified getDeclaredMethods() order.

The design document is included in the diff at docs/superpowers/specs/2026-08-14-WW-5675-security-member-access-config-sharing-design.md.

lukaszlenart and others added 15 commits August 14, 2026 13:45
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on path

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ty config

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge breaks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Names

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nstances

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…assertions

Two review findings: (1) the config-derived-set assertions compared instance to
instance only, which is vacuous for every emptySet()-defaulted field since
Collections.emptySet() is a JVM-wide singleton shared by both the config bean's
own default and SecurityMemberAccess's own default; deleting a useConfig
assignment for such a field would still pass. Fixed by additionally asserting
each field directly against the shared SecurityMemberAccessConfig bean, with
the container reloaded to set every relevant constant away from its hardcoded
default so the comparison is not itself vacuous by coincidence. (2)
testConfigBeanIsASingleton passed on assertSame(null, null) when the bean
was not registered at all; added assertNotNull before the identity check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cher container

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oved

Dispatcher installs its own provider list and never adds
StrutsDefaultConfigurationProvider, so bootstrapFactories is not on the
production path. The bean needs registering in struts-beans.xml too,
matching ProviderAllowlist and ThreadAllowlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Final-review cleanup: SecurityMemberAccess.applyAllowlistPackageNames was
still allocating a fresh HashSet per instantiation, landing precisely on
deployments that configure struts.allowlist.packageNames. Move
ALLOWLIST_REQUIRED_PACKAGES and union(...) onto SecurityMemberAccessConfig,
which now precomputes allowlistPackageNamesUnion once per container;
useConfig copies the reference, and the deprecated setter path reuses the
same static union() method, so there remains exactly one computation site.

Also: mark the eleven deprecated SecurityMemberAccess setters with
since/forRemoval per repo convention, document union()'s Set.of(...)
aliasing contract, pin allowlistPackageNamesUnion into the immutability and
dev-mode-field-removal tests, restore alphabetical import order in
ConfigParseUtilTest, switch the sharing test off the Map.of ten-pair
ceiling, and correct the design doc's bootstrap-container wiring claim and
drop its unimplemented counting-probe promise.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes Struts’ OGNL security gate initialization cost by moving repeated parsing of SecurityMemberAccess configuration (previously re-done per prototype instance) into a container-scoped singleton (SecurityMemberAccessConfig) and having each SecurityMemberAccess instance copy immutable, already-parsed references.

Changes:

  • Introduces SecurityMemberAccessConfig (singleton) to parse OGNL security configuration once per container, including dev-mode resolution and precomputed allowlist package union.
  • Wires the new config bean into SecurityMemberAccess via a single required @Inject setter; removes the dev-mode lazy flip/state from the access path and collapses allowlist package matching to a single precomputed union.
  • Hoists whitespace pattern compilation in ConfigParseUtil.validatePackageNames and adds/updates tests to prove sharing, isolation, production registration, and behavior preservation.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/superpowers/specs/2026-08-14-WW-5675-security-member-access-config-sharing-design.md Design spec describing the singleton config approach, registration paths, behavior constraints, and follow-ups.
docs/superpowers/plans/2026-08-14-WW-5675-share-parsed-ognl-security-config.md Implementation plan detailing tasks, wiring steps, and test strategy.
core/src/main/java/org/apache/struts2/util/ConfigParseUtil.java Hoists whitespace Pattern to a static constant to avoid per-element compilation.
core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccessConfig.java New singleton bean owning parsing/dev-mode selection and allowlist package union computation.
core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java Injects shared config, removes dev-mode flip, adds allowlist union field, collapses package matching helper signature, and deprecates remaining configuration setters.
core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java Registers SecurityMemberAccessConfig as a singleton in bootstrap factories.
core/src/main/resources/struts-beans.xml Registers SecurityMemberAccessConfig for the production Dispatcher container path.
core/src/test/java/org/apache/struts2/util/ConfigParseUtilTest.java Adds characterization tests for whitespace validation behavior.
core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java Extends immutability assertions to include allowlistPackageNamesUnion.
core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessPackageMatchingTest.java Updates tests for the new two-arg package-matching helper and adds union invariants.
core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessConfigTest.java New differential-style tests validating config parsing, dev-mode application, and union behavior.
core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessConfigSharingTest.java New tests proving cross-instance sharing, instance isolation, subclass injection behavior, and dev-mode eager application.
core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessConfigProductionRegistrationTest.java New test ensuring singleton registration in the production container (via struts-beans.xml).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

lukaszlenart and others added 3 commits August 14, 2026 17:01
…haring

Hoists the throwing Set.of(...) calls out of three assertThrows lambdas
in ConfigParseUtilTest (java:S5778); removes the now-dead
allowlistPackageNames field from SecurityMemberAccess, which was
written but never read after the union moved onto the config bean
(java:S1068), updating the two tests that reflected on it so the
meaningful allowlistPackageNamesUnion assertions remain; documents on
all eleven deprecated setters that the container no longer invokes
them, so a subclass override silently stops taking effect; corrects
two factual claims in the design doc about when the missing-binding
failure and the dev-mode warning actually fire, given the main
container is built lazily via builder.create(false); and narrows
SecurityMemberAccessConfig's sixteen use* setters from public to
package-private, since ContainerImpl injects via setAccessible and
narrower is a smaller blast radius for a container-wide singleton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… documenting it

Set.copyOf short-circuits to the same instance for an already-immutable
set, so the usual path still allocates nothing while a mutable argument
would be copied rather than aliased into a container-wide shared set.

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

Copy link
Copy Markdown

@lukaszlenart
lukaszlenart marked this pull request as ready for review August 14, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants