[
https://issues.apache.org/jira/browse/WW-5675?focusedWorklogId=1035824&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1035824
]
ASF GitHub Bot logged work on WW-5675:
--------------------------------------
Author: ASF GitHub Bot
Created on: 14/Aug/26 15:04
Start Date: 14/Aug/26 15:04
Worklog Time Spent: 10m
Work Description: lukaszlenart commented on code in PR #1847:
URL: https://github.com/apache/struts/pull/1847#discussion_r3784890777
##########
docs/superpowers/specs/2026-08-14-WW-5675-security-member-access-config-sharing-design.md:
##########
@@ -0,0 +1,354 @@
+# WW-5675 — Share parsed OGNL security configuration across
`SecurityMemberAccess` instances
+
+**Ticket:** [WW-5675](https://issues.apache.org/jira/browse/WW-5675) (sub-task
of [WW-5667](https://issues.apache.org/jira/browse/WW-5667))
+**Target:** 7.4.0
+**Date:** 2026-08-14
+**Status:** Design approved, pending implementation plan
+
+## Problem
+
+`SecurityMemberAccess` is a `Scope.PROTOTYPE` bean, registered in two places:
+
+- `DefaultConfiguration.java:416` — `.factory(SecurityMemberAccess.class,
Scope.PROTOTYPE)`
+- `StrutsBeanSelectionProvider.java:456` — aliased to `STRUTS_MEMBER_ACCESS`,
making it user-overridable
+
+Every `container.getInstance(SecurityMemberAccess.class)` therefore constructs
a fresh instance and re-runs all
+sixteen `@Inject` configuration setters, each of which re-parses a raw
comma-delimited string from scratch. With
+the stock `struts-excluded-classes.xml` that is roughly 90 configuration
entries per instantiation: comma
+splitting, `strip`, classloader validation, `Pattern.compile`, and `HashSet`
accumulation.
+
+New instances are created on the request path from at least:
+
+- `OgnlValueStackFactory.createValueStack(...)` — once per value stack, and
`ParametersInterceptor` creates an
+ additional stack per request
+- `OgnlUtil.createDefaultContext(Object, ClassResolver)` at
`OgnlUtil.java:738` — reached from `setProperties`,
+ `copy`, `getBeanMap` and friends. Note `OgnlUtil.copy` calls it **twice**
(`OgnlUtil.java:551-552`), so a single
+ copy costs two full configuration rebuilds.
+
+This is the dominant half of the parent report. The sibling ticket WW-5674
(merged as `81b34c295`) addressed the
+per-*access* allocations; this ticket addresses the per-*instantiation* cost,
which is where the reported 9% lives.
+
+The fix proposed on the parent ticket — caching the parsed set in a
`SecurityMemberAccess` field — cannot work,
+because the instance holding the field is itself discarded and rebuilt each
time.
+
+### Also in scope
+
+`ConfigParseUtil.validatePackageNames` (`ConfigParseUtil.java:143`) evaluates
`Pattern.compile("\\s")` once per
+package name rather than once overall — roughly 58 recompiles of a trivial
pattern per instantiation under the
+default configuration. Hoist it to a static constant.
+
+## Goals
+
+- Parse the OGNL security configuration once per container instead of once per
`SecurityMemberAccess`.
+- Preserve OGNL allow/deny semantics exactly. No configuration may become more
permissive.
+- Keep source compatibility for 7.4.0: existing subclasses and direct setter
callers must continue to compile and
+ behave identically. The five dev-mode setters are the one signed-off
exception — see "`SecurityMemberAccess`
+ changes".
+- Collapse the two-set allowlist walk introduced by WW-5674 into a single
precomputed set.
+
+## Non-goals
+
+- Changing array/primitive package-resolution semantics — that is WW-5676,
deliberately separate because it is a
+ security-semantics decision rather than a performance fix.
+- Removing the residual per-access `getPackage()` lookups — that is WW-5677.
+- Removing the deprecated setters. They are scheduled for 8.0.0 (see
Follow-ups).
+- Adding JMH or any benchmarking infrastructure to the build.
+
+## Approach
+
+Introduce a container-singleton configuration bean that owns all parsing.
`SecurityMemberAccess` stays
+`Scope.PROTOTYPE` and copies immutable set *references* out of that bean.
+
+Two alternatives were considered and rejected:
+
+**Memoize parsing inside `ConfigParseUtil`** (keyed by raw config string,
following the existing Caffeine
+precedent in that file). Smallest possible diff and no API change, but it
recovers the least: every instantiation
+still invokes sixteen setters, still builds the accumulated `HashSet` copies,
and still runs the lazy dev-mode
+flip. It also does not unblock the allowlist union collapse.
+
+**Revert `SecurityMemberAccess` to `Scope.SINGLETON`**, relocating
`acceptProperties`/`excludeProperties` into the
+OGNL context. Largest theoretical win, but it reverses a deliberate WW-5343
decision, converts two fields into
+shared mutable state requiring thread-safety on the OGNL security gate, and
changes the `MemberAccessValueStack`
+contract that `ParametersInterceptor` depends on. Under the chosen approach
the per-instantiation cost is already
+about a dozen reference copies, so this buys very little for substantially
more risk.
+
+## Design
+
+### New bean: `SecurityMemberAccessConfig`
+
+Registered in `DefaultConfiguration` beside the existing internal singletons:
+
+```java
+.factory(SecurityMemberAccessConfig.class, Scope.SINGLETON)
+```
+
+Concrete class, no interface, **not** aliased in
`StrutsBeanSelectionProvider`. It is internal plumbing, following
+the shape of `ProviderAllowlist` and `ThreadAllowlist`
(`DefaultConfiguration.java:418-419`), not a user extension
+point.
+
+**The bean must be registered in two places.** An earlier draft of this design
claimed `bootstrapFactories` was on
+the production path because
`ConfigurationManager.addDefaultContainerProviders`
(`ConfigurationManager.java:94`)
+registers `StrutsDefaultConfigurationProvider`, which calls it at
+`StrutsDefaultConfigurationProvider.java:116`. **That claim is wrong**, and it
was only caught when the full core
+suite failed with 1579 errors during implementation.
+
+`ConfigurationManager.addDefaultContainerProviders()` fires only when
`containerProviders.isEmpty()`
+(`ConfigurationManager.java:78-80`). `Dispatcher.init()`
(`Dispatcher.java:711-719`) installs its own provider
+list — including `StrutsBeanSelectionProvider` via `init_AliasStandardObjects`
— so the list is never empty and
+`StrutsDefaultConfigurationProvider` is never added. The production container
is built from
+`StrutsBeanSelectionProvider` plus `struts-beans.xml`, and
`bootstrapFactories` is not on the path of that *main
+Dispatcher* container.
+
+It is, however, on a different, load-bearing path:
`DefaultConfiguration.reloadContainer` builds a **bootstrap**
+container from `bootstrapFactories` (`DefaultConfiguration.java:283`, via
`createBootstrapContainer` at
+`DefaultConfiguration.java:348-373`), then calls `setContext(bootstrap)`
(`DefaultConfiguration.java:307`), which
+calls `bootstrap.getInstance(ValueStackFactory.class).createValueStack()` —
and building a value stack instantiates
+`SecurityMemberAccess` through `CompoundRootAccessor`/`RootAccessor`. So the
bootstrap container's registration
+of `SecurityMemberAccessConfig` is not a fallback for some other, unused path:
it is exercised on every
+`reloadContainer()` call, before the main Dispatcher container even exists.
+
+The registration therefore goes in both places, which is precisely what
`ProviderAllowlist` and `ThreadAllowlist`
+already do — `DefaultConfiguration.java:418-419` and
`struts-beans.xml:175-176`:
+
+```xml
+ <bean class="org.apache.struts2.ognl.SecurityMemberAccessConfig"/>
+```
+
+The `DefaultConfiguration` registration serves the bootstrap container
(`DefaultConfiguration.java:360`) and the
+`XWorkTestCase` harness; the `struts-beans.xml` entry serves the real
Dispatcher container. **Both registrations
+are load-bearing** — production would throw at startup without either, since
`useConfig` is a mandatory `@Inject`
+on `SecurityMemberAccess`. The bootstrap container carries only
`BOOTSTRAP_CONSTANTS`, so most security constants
+are absent there, the `required = false` setters do not fire, and the bean
falls back to defaults — exactly as a
+`SecurityMemberAccess` constructed in that container behaves today.
+
+This failure mode is loud, not silent: `useConfig` is a mandatory `@Inject`,
so a container missing the binding
+throws at build time rather than running with empty exclusions.
+
+The `TODO: SpringObjectFactoryTest fails when these are SINGLETON` comment at
the top of `bootstrapFactories`
+applies to the `*Factory` beans in the first block, not to this region, where
singletons are already the norm.
+
+It takes over these sixteen `@Inject` setters from `SecurityMemberAccess`:
+
+| Setter | Constant |
+|---|---|
+| `useAllowStaticFieldAccess` | `STRUTS_ALLOW_STATIC_FIELD_ACCESS` |
+| `useExcludedClasses` | `STRUTS_EXCLUDED_CLASSES` |
+| `useExcludedPackageNamePatterns` | `STRUTS_EXCLUDED_PACKAGE_NAME_PATTERNS` |
+| `useExcludedPackageNames` | `STRUTS_EXCLUDED_PACKAGE_NAMES` |
+| `useExcludedPackageExemptClasses` | `STRUTS_EXCLUDED_PACKAGE_EXEMPT_CLASSES`
|
+| `useEnforceAllowlistEnabled` | `STRUTS_ALLOWLIST_ENABLE` |
+| `useAllowlistClasses` | `STRUTS_ALLOWLIST_CLASSES` |
+| `useAllowlistPackageNames` | `STRUTS_ALLOWLIST_PACKAGE_NAMES` |
+| `useDisallowProxyObjectAccess` | `STRUTS_DISALLOW_PROXY_OBJECT_ACCESS` |
+| `useDisallowProxyMemberAccess` | `STRUTS_DISALLOW_PROXY_MEMBER_ACCESS` |
+| `useDisallowDefaultPackageAccess` | `STRUTS_DISALLOW_DEFAULT_PACKAGE_ACCESS`
|
+| `useDevMode` | `STRUTS_DEVMODE` |
+| `useDevModeExcludedClasses` | `STRUTS_DEV_MODE_EXCLUDED_CLASSES` |
+| `useDevModeExcludedPackageNamePatterns` |
`STRUTS_DEV_MODE_EXCLUDED_PACKAGE_NAME_PATTERNS` |
+| `useDevModeExcludedPackageNames` | `STRUTS_DEV_MODE_EXCLUDED_PACKAGE_NAMES` |
+| `useDevModeExcludedPackageExemptClasses` |
`STRUTS_DEV_MODE_EXCLUDED_PACKAGE_EXEMPT_CLASSES` |
+
+`setProxyService` and the `@Inject` constructor stay on `SecurityMemberAccess`
— those inject collaborators, not
+configuration.
+
+The bean implements `Initializable`. Dev-mode resolution cannot happen inside
any individual setter, because
+`ContainerImpl.addInjectorsForMembers` iterates `getDeclaredMethods()`, whose
order the JDK explicitly leaves
+unspecified. `Initializable.init()` runs after the whole dependency graph is
built
+(`InitializableFactory.wrapIfNeeded`, applied from `Scope` for singleton
scope; `DefaultValidatorFactory` is the
+existing precedent). `init()` therefore:
+
+1. Selects the effective excluded sets — dev-mode variants when
`struts.devMode=true`, otherwise the normal ones.
+2. Precomputes the allowlist package union.
+
+The bean exposes only immutable getters, and publishes the **effective**
excluded sets with dev-mode already
+applied, so nothing downstream needs to know dev-mode exists.
+
+### `SecurityMemberAccess` changes
+
+Gains exactly one injected member:
+
+```java
+@Inject
+public void useConfig(SecurityMemberAccessConfig config) { … }
+```
+
+which seeds its fields by copying immutable set references — no parsing, no
`HashSet` construction, no
+`Pattern.compile`.
+
+**Fields removed:** `isDevModeInit` (volatile), `isDevMode`,
`devModeExcludedClasses`,
+`devModeExcludedPackageNamePatterns`, `devModeExcludedPackageNames`,
`devModeExcludedPackageExemptClasses`.
+
+**Method removed:** `useDevModeConfiguration()`, along with its call from
`checkExclusionList`
+(`SecurityMemberAccess.java:264`). The lazy dev-mode flip disappears from the
access path entirely.
+
+**Field added:** `allowlistPackageNamesUnion`.
+
+The five dev-mode setters are **deleted outright rather than deprecated** —
decided 2026-08-14. This is a
+deliberate, signed-off deviation from the "additive and deprecate, no breakage
in a minor" policy that governs the
+rest of this change.
+
+They are `public`, but only ever container-injected, with no direct caller
anywhere in core, plugins, or tests.
+Preserving them faithfully would mean keeping `isDevMode` plus the four
dev-mode set fields on the instance and
+reinstating some form of the lazy flip — that is, keeping precisely the code
this change exists to delete, to
+serve a caller that does not demonstrably exist. Retention in simplified form
was rejected because today's
+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 accepted risk is that a deployment calling these methods directly breaks
at compile time on upgrade to 7.4.0.
+This is a loud, immediate failure with an obvious fix, not a silent
behavioural change, which is what makes it
+acceptable where the constructor break discussed below was not.
+
+The remaining eleven configuration setters stay as `@Deprecated` methods with
their `@Inject` annotations removed.
+They keep mutating that instance exactly as they do now. Deprecation is by
annotation only — no runtime warnings,
+which would flood test output given roughly 110 direct call sites across core
and plugins.
+
+`useAllowStaticFieldAccess` retains its side effect of calling
`useExcludedClasses(Class.class.getName())`, and
+the configuration bean must reproduce that accumulation exactly.
+
+### Why setter injection rather than constructor injection
+
+Constructor injection would be the obvious way to guarantee ordering, but it
forces a constructor signature
+change. A user subclass calling `super(providerAllowlist, threadAllowlist)` —
precisely the shape of the existing
+`ExternalSecurityMemberAccess` test fixture — would then either fail to
compile, or, if a deprecated 2-arg
+overload were retained, compile cleanly and silently run with empty
exclusions. **That is a fail-open hole**, and
+the kind that fails silently rather than loudly.
+
+Setter injection avoids it: `ContainerImpl.addInjectors` recurses into
superclasses first
+(`ContainerImpl.java:97`), so inherited `@Inject` setters are injected on
subclass instances. Existing subclasses
+keep compiling *and* receive the configuration.
+
+Injection ordering is safe by construction. Today the setters survive
unspecified ordering only because they
+*accumulate* rather than assign, making them commutative — a subtlety that is
easy to destroy accidentally. After
+this change `SecurityMemberAccess` has exactly one injected member touching
those fields, so ordering stops
+mattering at all.
+
+A null configuration is also safe: the eight direct `new
SecurityMemberAccess(null, null)` test sites never have
+the setter called, so their fields keep today's hardcoded defaults. Reads only
ever touch fields, never the
+configuration object, so there is no null path on the access path.
+
+### Allowlist union
+
+With the sets precomputed per container, `isClassAllowlisted` collapses to a
single set and a single walk:
+
+```java
+|| isClassBelongsToPackages(clazz, allowlistPackageNamesUnion);
+```
+
+This deletes the three-argument `isClassBelongsToPackages` overload and the
two-set parameters on
+`isPackageBelongsToPackages`, resolving WW-5678's first item as a side effect.
+
+The ticket flagged this as a fail-open hazard: if the union were computed in
two places — once seeded from
+configuration, once when the deprecated `useAllowlistPackageNames` setter
fires — the two could drift, silently
+dropping `ALLOWLIST_REQUIRED_PACKAGES` from the allowlist with nothing failing
loudly. It is also a fail-open
+hazard if the union is *re-computed* per instance: that reintroduces exactly
the per-instantiation `HashSet`
+allocation this ticket exists to remove, and lands on the deployments that
configure the allowlist properly,
+inverting the ticket's intent.
+
+Both hazards are avoided by moving `ALLOWLIST_REQUIRED_PACKAGES` and the
`union(...)` helper onto
+`SecurityMemberAccessConfig`, which precomputes `allowlistPackageNamesUnion`
once, inside its own
+`useAllowlistPackageNames` setter, when the constant fires during container
construction:
+
+```java
+// SecurityMemberAccessConfig
+static final Set<String> ALLOWLIST_REQUIRED_PACKAGES = Set.of(
+ "org.apache.struts2.validator.validators",
+ "org.apache.struts2.components",
+ "org.apache.struts2.views.jsp"
+);
+
+public void useAllowlistPackageNames(String commaDelimitedPackageNames) {
+ this.allowlistPackageNames = toPackageNamesSet(commaDelimitedPackageNames);
+ this.allowlistPackageNamesUnion = union(ALLOWLIST_REQUIRED_PACKAGES,
allowlistPackageNames);
+}
+
+static Set<String> union(Set<String> required, Set<String> configured) { … }
+```
+
+`SecurityMemberAccess.useConfig` copies the precomputed reference
(`config.getAllowlistPackageNamesUnion()`) —
+no allocation on the hot instantiation path. Its deprecated
`useAllowlistPackageNames` setter, which still mutates
+a single instance directly and has no `SecurityMemberAccessConfig` to read
from, calls the same
+`SecurityMemberAccessConfig.union(...)` static method. Both routes therefore
funnel through the one method, so
+exactly one line in the codebase computes the union, and
`ALLOWLIST_REQUIRED_PACKAGES` cannot drift out of it
+through a second implementation. The constant and helper live on the config
bean — the class that owns computing
+and exposing configuration-derived state — rather than being duplicated onto
`SecurityMemberAccess`, whose
+deprecated setter merely calls back into it.
+
+`isPackageBelongsToPackages` currently early-returns on `first.isEmpty() &&
second.isEmpty()`. Since
+`ALLOWLIST_REQUIRED_PACKAGES` is never empty, that guard simply stops firing
on the allowlist path; the exclusion
+path, where both sets genuinely can be empty, keeps it. The guard was only
ever an optimization, so this is not a
+semantic change.
+
+## Data flow
+
+| Tier | Frequency | Work |
+|---|---|---|
+| `SecurityMemberAccessConfig` construction | Once per container | All
parsing, class validation, pattern compilation, dev-mode resolution, union
precomputation |
+| `useConfig` | Once per `SecurityMemberAccess` | About a dozen immutable
reference copies |
+| `ParametersInterceptor` | Once per request | Sets
`acceptProperties`/`excludeProperties` on the instance (unchanged) |
+| `isAccessible` | Per OGNL member access | Field reads only |
+
+## Error handling
+
+Parsing failures — `ConfigurationException` for an unloadable class, an
invalid regex, or whitespace in a package
+name — move from being thrown on every instantiation to being thrown once,
when the singleton is first built.
+Still fatal, still loud, just earlier and once. Nothing degrades to a warning.
+
+The `struts.allowlist.enable=false` warning already dedupes via
`logWarningForFirstOccurrence`; moving it to the
+configuration bean makes it a genuine once-per-container event.
+
+## Behaviour changes
+
+One, accepted during design review: the `"DevMode enabled, using DevMode
excluded classes and packages for OGNL
+security enforcement!"` warning currently fires on the first OGNL access and
will now fire when the configuration
+singleton is built. This makes it a deterministic startup signal rather than
one contingent on traffic.
+
+No other externally visible behaviour changes. OGNL allow/deny semantics are
identical.
+
+## Testing
+
+Core tests are JUnit 4 or extend `XWorkTestCase`. A JUnit 5 `@Test` added to
these suites silently never runs.
+
+1. **Sharing proof.** Request several `SecurityMemberAccess` instances from
one container and assert their
+ configuration-derived sets are reference-identical (`assertSame`, not
`assertEquals`). Reference identity is a
+ dependency-free proof that no re-parsing occurred, since any re-parse
necessarily produces a fresh set; this is
+ the sound substitute for a counting probe and is what the implementation
actually asserts.
+2. **Instance isolation.** Calling a deprecated setter on one instance must
not perturb a sibling instance or the
+ singleton. The sets are `unmodifiableSet`, so an in-place mutation bug
would throw rather than corrupt
+ silently, but this invariant deserves an explicit assertion.
+3. **Subclass injection.** A subclass declaring the 2-arg constructor and
calling
+ `super(providerAllowlist, threadAllowlist)` must receive the configuration
through the inherited setter. This
+ is the test that would catch a future refactor to constructor injection
reintroducing the fail-open hole.
+4. **Behaviour preservation.** Following WW-5674's differential pattern: for
default, dev-mode, and custom
+ configurations, the sets the new bean publishes must equal what a
legacy-style accumulation produces. This is
+ where the `useAllowStaticFieldAccess` → `useExcludedClasses` side effect
gets pinned down.
+5. **Dev-mode.** With `struts.devMode=true` the effective sets are the
dev-mode ones from the start, with no OGNL
+ access required to trigger the switch.
+
+The principal safety net is the existing suite. `SecurityMemberAccessTest` and
its siblings drive these setters
+directly from roughly 110 call sites across core and plugins and must pass
untouched. If the deprecated setters
+have kept their exact semantics, that suite cannot tell the difference — the
strongest available evidence that
+OGNL allow/deny semantics are unchanged.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| Shared sets mutated in place, poisoning every instance in the container |
Sets are already `unmodifiableSet`; test 2 asserts isolation explicitly |
+| Allowlist union drifts from `ALLOWLIST_REQUIRED_PACKAGES` (fail-open) |
Single computation site; test 4 covers custom allowlist configurations |
+| Configuration bean fails to reproduce the accumulate-not-assign semantics |
Test 4 is differential against the legacy accumulation, not against
hand-written expectations |
+| A future refactor moves configuration to constructor injection,
reintroducing the silent fail-open | Test 3 encodes the subclass contract; the
rationale is recorded above and in the class Javadoc |
+| `Initializable` is documented "should be only used internally" | The bean is
internal and unaliased; `DefaultValidatorFactory` is the existing precedent |
+
+## Follow-ups
+
+- **8.0.0 — remove the deprecated configuration setters.** The eleven methods
left on `SecurityMemberAccess`
+ should be removed once the major version allows it. To be filed as its own
ticket, cross-referencing WW-5675 and
+ WW-5678.
+- **WW-5678** — its first item (the package-private overload sharing a name
with a public method) is resolved for
+ free here by the union collapse. The remaining visibility narrowing stays
with that ticket.
+- **WW-5667** — the parent should be updated to note that this ticket, not
WW-5674, is the one expected to move
+ the reported 9%.
+- **Migration guide entry for 7.4.0** — the removal of the five dev-mode
setters is a source-breaking change in a
+ minor release and must be called out in the Version Notes and Migration
Guide, however narrow the affected
+ audience.
Review Comment:
Agreed, and tracked as
[WW-5684](https://issues.apache.org/jira/browse/WW-5684).
It can't land in this PR's diff — the Version Notes and Migration Guide are
cwiki pages rather than files in the repository — so an explicitly linked
follow-up is the right resolution here.
The ticket covers more than the five removals. An independent review pass
found a second, *silent* change worth documenting: the eleven retained
configuration setters kept their bodies but lost their `@Inject` annotations,
so a subclass registered via `struts.ognl.memberAccess` that overrides one of
them to append its own exclusions no longer has that override invoked. The
subclass still receives the full container-parsed baseline through `useConfig`,
so it loses only its own additions — but it loses them with no compile error
and no log line, which arguably makes it more important for upgraders than the
removals that fail loudly.
Issue Time Tracking
-------------------
Worklog Id: (was: 1035824)
Time Spent: 40m (was: 0.5h)
> Stop re-parsing OGNL security config on every SecurityMemberAccess
> instantiation
> --------------------------------------------------------------------------------
>
> Key: WW-5675
> URL: https://issues.apache.org/jira/browse/WW-5675
> Project: Struts 2
> Issue Type: Sub-task
> Reporter: Lukasz Lenart
> Assignee: Lukasz Lenart
> Priority: Major
> Fix For: 7.4.0
>
> Time Spent: 40m
> Remaining Estimate: 0h
>
> Sub-task of WW-5667.
> {{SecurityMemberAccess}} is a {{Scope.PROTOTYPE}} bean
> ({{StrutsBeanSelectionProvider}}, since WW-5343). Every
> {{container.getInstance(SecurityMemberAccess.class)}} therefore constructs a
> fresh instance and re-runs all of its {{@Inject}} setters, each of which
> re-parses a raw comma-delimited configuration string from scratch.
> With the stock {{struts-excluded-classes.xml}}, a single instantiation
> re-parses roughly:
> * 16 excluded class names from {{struts.excludedClasses}} and another 16 from
> {{struts.devMode.excludedClasses}} — comma split plus classloader validation
> * 29 excluded package names from {{struts.excludedPackageNames}} and another
> 29 from {{struts.devMode.excludedPackageNames}} — comma split, {{strip}}, and
> {{validatePackageNames}}
> * the allowlist class and package sets
> * any configured excluded-package-name patterns, which are re-compiled via
> {{Pattern.compile}}
> New instances are created on the request path from at least:
> * {{OgnlValueStackFactory.createValueStack(...)}} — once per value stack;
> {{ParametersInterceptor.toNewStack}} creates an additional stack per request
> * {{OgnlUtil.createDefaultContext(Object, ClassResolver)}} — reached from
> {{setProperties}}, {{copy}}, {{getBeanMap}} and friends
> so the full configuration is rebuilt several times per request.
> This matches JFR sample 2 on the parent ticket:
> {noformat}
> HashMap.put / HashSet.add
> ConfigParseUtil.toNewPackageNamesSet(Collection, String) :138
> SecurityMemberAccess.useExcludedPackageNames(String) :446
> {noformat}
> Note that the fix proposed on the parent ticket — caching the parsed set in a
> {{SecurityMemberAccess}} field — does not address this, because the instance
> holding the field is itself discarded and rebuilt each time.
> h2. Also in scope: validatePackageNames recompiles a constant pattern per
> entry
> {{ConfigParseUtil.validatePackageNames}} evaluates {{Pattern.compile("\\s")}}
> once per package name rather than once overall:
> {code:java}
> public static void validatePackageNames(Collection packageNames) {
> if (packageNames.stream().anyMatch(s ->
> Pattern.compile("\\s").matcher(s).find())) {
> throw new ConfigurationException("Excluded package names could not be
> parsed due to erroneous whitespace characters: " + packageNames);
> }
> }
> {code}
> With the default configuration that is roughly 58 recompiles of a trivial
> pattern per {{SecurityMemberAccess}} instantiation, on top of the parsing
> work above. Hoisting the pattern to a static constant — or replacing the
> check with a plain character scan — is a small, self-contained fix, and it
> shares this ticket's root cause of doing per-instantiation work that should
> be done once.
> h2. Design note: this ticket unblocks collapsing the allowlist two-set walk
> WW-5674 merged the two allowlist walks in {{isClassAllowlisted}} by adding a
> two-set helper:
> {code:java}
> static boolean isPackageBelongsToPackages(String packageName, Set first, Set
> second)
> {code}
> The cleaner shape is a single set holding the union of
> {{ALLOWLIST_REQUIRED_PACKAGES}} and {{allowlistPackageNames}}, precomputed
> once. That would delete the two-set parameter and the three-argument overload
> entirely, leaving one set and one walk.
> It was rejected in WW-5674 for two reasons, and _this ticket removes the
> first and largely removes the second_:
> h1. Under the current prototype scope, a precomputed union field is rebuilt
> on every instantiation — several times per request — adding to exactly the
> cost this ticket is about. Once the parsed configuration is shared rather
> than per-instance, the union becomes free.
> h1. The union would need initialising in two places: at the field
> declaration, for the case where the {{@Inject}} setter never fires because no
> allowlist is configured, and again in the setter. Duplicated initialisation
> here is a _fail-open_ hazard — get it wrong and
> {{ALLOWLIST_REQUIRED_PACKAGES}} silently drops out of the allowlist with
> nothing failing loudly. Whatever this ticket does to give the configuration a
> single well-defined construction point makes that much safer to get right.
> Worth doing as part of this ticket rather than leaving it to WW-5678, since
> the enabling change lands here. Note that it would also resolve WW-5678's
> first item for free, by removing the package-private overload that shares a
> name with a public method.
> h2. Constraints
> The OGNL allow/deny semantics must not change. The dev-mode configuration
> switchover in {{useDevModeConfiguration()}} must keep working. Per-instance
> mutable state set at request time by
> {{ParametersInterceptor.applyMemberAccessProperties}}
> ({{useAcceptProperties}} / {{useExcludeProperties}}) must stay per-instance —
> this is why the bean was made prototype-scoped in the first place, so any
> move back towards sharing has to account for it.
> h2. Related
> * WW-5674 — the per-access half of the parent report
> ({{isClassBelongsToPackages}} allocations). Handled separately; it does not
> move this cost.
> * WW-5677 — remaining redundant {{getPackage()}} lookups on the per-access
> path. Same file, different root cause.
> * WW-5678 — helper naming and visibility cleanup for 8.0.0. Its first item
> may be resolved for free here, see the design note above.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)