WW-5675 Share parsed OGNL security configuration across SecurityMemberAccess instances - #1847
Open
lukaszlenart wants to merge 18 commits into
Open
WW-5675 Share parsed OGNL security configuration across SecurityMemberAccess instances#1847lukaszlenart wants to merge 18 commits into
lukaszlenart wants to merge 18 commits into
Conversation
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>
Contributor
There was a problem hiding this comment.
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
SecurityMemberAccessvia a single required@Injectsetter; 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.validatePackageNamesand 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.
…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>
|
lukaszlenart
marked this pull request as ready for review
August 14, 2026 16:07
lukaszlenart
requested review from
aleksandr-m,
cnenning,
jogep,
kusalk,
rgielen,
sdutry and
yasserzamani
August 14, 2026 16:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes WW-5675 — sub-task of WW-5667.
The problem
SecurityMemberAccessis aScope.PROTOTYPEbean, 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@Injectconfiguration setters, each re-parsing a raw comma-delimited string from scratch: comma splitting,strip, classloader validation,Pattern.compile, andHashSetaccumulation. With the stockstruts-excluded-classes.xmlthat is roughly 90 configuration entries rebuilt per instantiation.OgnlUtil.copyalone callscreateDefaultContexttwice, 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
SecurityMemberAccessfield — 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.SecurityMemberAccessstaysScope.PROTOTYPEand receives that bean through a single@Injectsetter, copying immutable set references — no parsing, noHashSetconstruction, noPattern.compileper instantiation.Also in this PR:
useDevModeConfiguration()plus avolatileguard) is gone from the OGNL access path. Dev-mode now resolves once, in the config bean'sInitializable.init().ConfigParseUtil.validatePackageNamesno longer recompilesPattern.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.addInjectorsrecurses into superclasses first, so an existing user subclass callingsuper(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.useConfigis a mandatory@Inject, so a container missing the binding throws at build time instead of running with weaker exclusions.Registration in two places
SecurityMemberAccessConfigis registered in bothDefaultConfiguration.bootstrapFactoriesandstruts-beans.xml, mirroringProviderAllowlistandThreadAllowlist. Both are load-bearing:Dispatcher.init()installs its own provider list and never addsStrutsDefaultConfigurationProvider, so the main container is built fromStrutsBeanSelectionProviderplusstruts-beans.xml.DefaultConfiguration.reloadContainerbuilds a bootstrap container frombootstrapFactoriesand instantiatesSecurityMemberAccessthrough it before the main container exists.Production throws at startup without either.
Five dev-mode setters are deleted from
SecurityMemberAccess— fourpublic, oneprotected:useDevModeExcludedClassespublicstruts.devMode.excludedClassesuseDevModeExcludedPackageNamePatternspublicstruts.devMode.excludedPackageNamePatternsuseDevModeExcludedPackageNamespublicstruts.devMode.excludedPackageNamesuseDevModeExcludedPackageExemptClassespublicstruts.devMode.excludedPackageExemptClassesuseDevModeprotectedstruts.devModeuseDevModebeingprotectedmeans its removal is visible only to subclasses. The privateuseDevModeConfiguration()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 bySecurityMemberAccessConfiginstead of once perSecurityMemberAccessinstance.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
isDevModeplus 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 manualuseDevModeExcludedClassescall accumulates into the dev-mode set, which then replaces — rather than unions with —excludedClasseson 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
coresuite: 3181 tests, 0 failures, 0 errors.plugins/spring61/61,plugins/cdi17/17.New coverage, beyond the existing suites passing untouched:
SecurityMemberAccessinstances 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.StrutsInternalTestCaseboot (realDispatcher.init(), loadsstruts-beans.xml) asserts the bean is a singleton on the path production actually uses. Verified by temporarily settingscope="prototype"and confirming the test fails.useAllowStaticFieldAccess→useExcludedClassesside effect and the commutativity that makes the setters safe against the container's unspecifiedgetDeclaredMethods()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.