[ 
https://issues.apache.org/jira/browse/WW-5700?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Lukasz Lenart updated WW-5700:
------------------------------
    Description: 
h2. Summary

When conversion of a request parameter into a typed collection fails, Struts 
stores its internal "conversion failed" marker into the collection instead of 
skipping the assignment. The marker is itself a java.lang.String, so it lands 
in a collection declared to hold some other type. Because generics are erased 
at that point the store succeeds silently, and the ClassCastException is 
deferred until application code reads the entry back.

The resulting stack trace points at _application_ code rather than at Struts, 
which makes this very hard to recognise from a bug report.

Reported on the user list as "Struts setting a String object instead of Integer 
in the form" (2026-08-14), against 7.2.1 and 7.3.0.

h2. Root cause

TypeConverter.java line 49 declares the marker as an Object field whose value 
is the ordinary text "ognl.NoConversionPossible". XWorkConverter.convertValue 
returns that marker on failure (lines 339, 351 and 361), _after_ correctly 
registering the conversion error via handleConversionException.

Three property accessors then store that return value with no guard:
* XWorkMapPropertyAccessor.setProperty, around line 127 - both the key and the 
value are unguarded
* XWorkListPropertyAccessor.getRealValue, line 188
* XWorkCollectionPropertyAccessor.getRealValue, line 263

By contrast OGNL itself guards this correctly in OgnlRuntime at line 1323, 
which is why a plain non-collection property is left untouched on a failed 
conversion.

h2. Reproduced

On main at 05ad78a06, end-to-end through ParametersInterceptor with annotation 
enforcement enabled. Both halves reproduce.

_Value half_ - the reporter's case. An unchecked s:checkbox with 
submitUnchecked="true" causes CheckboxInterceptor to submit the parameter with 
its uncheckedValue, default "false". Bound into a HashMap with Long keys and 
Integer values:

{noformat}
key=100 value=[1] (java.lang.Integer)
key=200 value=[ognl.NoConversionPossible] (java.lang.String)
conversionErrors={capDeferral[200]=ConversionData@...}
{noformat}

_Key half_ - the marker is stored as a map _key_, which breaks iteration over 
the entire map rather than a single entry:

{noformat}
acceptable=[capDeferral['abc'], capDeferral[7]]
key=[ognl.NoConversionPossible] (java.lang.String) value=[1]
key=[7] (java.lang.Long) value=[2]
conversionErrors={capDeferral['abc']=ConversionData@...}
{noformat}

The key half is reachable because the accepted-parameter-name patterns in 
DefaultAcceptedPatternsChecker are asymmetric - the bare-bracket branch accepts 
digits only, but the quoted-key branch accepts word characters:

{noformat}
(\[\d+])                            bare brackets: digits only
(\['(\w-?|[\u4e00-\u9fa5]-?)+'])    quoted key: word characters
{noformat}

So {{capDeferral['abc']}} is an accepted parameter name even where the declared 
map key type is numeric, and nothing downstream re-checks the key against that 
type. This is worth stating explicitly because the natural "map indices are 
numeric" intuition does not hold.

Note the conversion error _is_ reported in both halves. This is therefore not a 
validation bypass: it only bites an action that reads the collection without 
acting on conversion errors.

h2. Fix

Guard for the marker and skip the store; the error has already been registered 
by convertValue, so nothing is lost. In the map accessor the key is guarded 
before the value is even converted, because a bad key poisons iteration over 
the whole map rather than one entry. In the list accessor the guard sits before 
the auto-grow block, so an unconvertible value does not grow the list.

The comparison is by reference rather than equals(). That is correct rather 
than incidental: the marker field is declared Object, not String, so it is not 
a JLS constant variable and is not inlined into referencing class files - every 
reference resolves to the one field value at runtime, third-party converters 
included. A parameter value built by a servlet container from request bytes is 
a distinct object, so reference comparison separates "the converter signalled 
failure" from "the user submitted this text".

To be precise about the limit: this protects values arriving from a request, 
which is the case that matters here. It does not protect a value that happens 
to be interned, since all identical String literals share one instance - 
application code calling the converter programmatically with such a literal 
would still lose it. Closing that as well would mean giving the marker an 
identity no user string can share, which changes a published constant and is a 
binary-compatibility question rather than a bug fix.

h2. Not included

XWorkCollectionPropertyAccessor carries the same unguarded pattern but is left 
untouched: its scalar setProperty path is not reachable through the value 
stack. Setting {{ids[0]}} on a Set is rejected by OGNL before it gets there, so 
no failing test could be written for it and it was not changed blind. Verified 
independently during review.

h2. Precedent

WW-3762 fixed this same bug class in XWorkBasicConverter.doConvertToCollection 
back in 2.3.3, and CollectionConverter still carries that guard today. The 
property accessors were simply never given the equivalent check.

WW-5701 is the mirror-image defect found while reviewing this fix: 
CollectionConverter's guard uses equals() and therefore drops a legitimate 
element whose text genuinely is the marker.

h2. Backward compatibility

Narrow. Previously an unconvertible entry was stored as the marker; now nothing 
is stored. Nothing can reasonably depend on the old behaviour, and the 
conversion error is reported either way, so validation-driven actions see no 
change at all.

h2. Status

Fixed in PR https://github.com/apache/struts/pull/1873 - three regression 
tests, written test-first and mutation-checked, plus an end-to-end test of the 
reported checkbox scenario. Full core suite green.

  was:
h2. Summary

When conversion of a request parameter into a typed collection fails, Struts 
stores its internal "conversion failed" marker into the collection instead of 
skipping the assignment. The marker is itself a java.lang.String, so it lands 
in a collection declared to hold some other type. Because generics are erased 
at that point the store succeeds silently, and the ClassCastException is 
deferred until application code reads the entry back.

The resulting stack trace points at _application_ code rather than at Struts, 
which makes this very hard to recognise from a bug report.

Reported on the user list as "Struts setting a String object instead of Integer 
in the form" (2026-08-14), against 7.2.1 and 7.3.0.

h2. Root cause

TypeConverter.java line 49 declares the marker as an Object field whose value 
is the ordinary text "ognl.NoConversionPossible". XWorkConverter.convertValue 
returns that marker on failure (lines 339, 351 and 361), _after_ correctly 
registering the conversion error via handleConversionException.

Three property accessors then store that return value with no guard:
* XWorkMapPropertyAccessor.setProperty, around line 127 - both the key and the 
value are unguarded
* XWorkListPropertyAccessor.getRealValue, line 188
* XWorkCollectionPropertyAccessor.getRealValue, line 263

By contrast OGNL itself guards this correctly in OgnlRuntime at line 1323, 
which is why a plain non-collection property is left untouched on a failed 
conversion.

h2. Reproduced

On main at 05ad78a06, end-to-end through ParametersInterceptor with annotation 
enforcement enabled. Both halves reproduce.

_Value half_ - the reporter's case. An unchecked s:checkbox with 
submitUnchecked="true" causes CheckboxInterceptor to submit the parameter with 
its uncheckedValue, default "false". Bound into a HashMap with Long keys and 
Integer values:

{noformat}
key=100 value=[1] (java.lang.Integer)
key=200 value=[ognl.NoConversionPossible] (java.lang.String)
conversionErrors={capDeferral[200]=ConversionData@...}
{noformat}

_Key half_ - the marker is stored as a map _key_, which breaks iteration over 
the entire map rather than a single entry:

{noformat}
acceptable=[capDeferral['abc'], capDeferral[7]]
key=[ognl.NoConversionPossible] (java.lang.String) value=[1]
key=[7] (java.lang.Long) value=[2]
conversionErrors={capDeferral['abc']=ConversionData@...}
{noformat}

The key half is reachable because the accepted-parameter-name patterns in 
DefaultAcceptedPatternsChecker are asymmetric - the bare-bracket branch accepts 
digits only, but the quoted-key branch accepts word characters:

{noformat}
(\[\d+])                      bare brackets: digits only
(\['(\w-?|[\u4e00-\u9fa5]-?)+'])   quoted key: word characters
{noformat}

So capDeferral['abc'] is an accepted parameter name even where the declared map 
key type is numeric, and nothing downstream re-checks the key against that 
type. This is worth stating explicitly because the natural "map indices are 
numeric" intuition does not hold.

Note the conversion error _is_ reported in both halves. This is therefore not a 
validation bypass: it only bites an action that reads the collection without 
acting on conversion errors.

h2. Fix

Guard for the marker and skip the store; the error has already been registered 
by convertValue, so nothing is lost. In the map accessor the key is guarded 
before the value is even converted, because a bad key poisons iteration over 
the whole map rather than one entry. In the list accessor the guard sits before 
the auto-grow block, so an unconvertible value does not grow the list.

The comparison is by reference rather than equals(). That is correct rather 
than incidental: the marker field is declared Object, not String, so it is not 
a JLS constant variable and is not inlined into referencing class files - every 
reference resolves to the one field value at runtime, third-party converters 
included. A parameter value built by a servlet container from request bytes is 
a distinct object, so reference comparison separates "the converter signalled 
failure" from "the user submitted this text".

To be precise about the limit: this protects values arriving from a request, 
which is the case that matters here. It does not protect a value that happens 
to be interned, since all identical String literals share one instance - 
application code calling the converter programmatically with such a literal 
would still lose it. Closing that as well would mean giving the marker an 
identity no user string can share, which changes a published constant and is a 
binary-compatibility question rather than a bug fix.

h2. Not included

XWorkCollectionPropertyAccessor carries the same unguarded pattern but is left 
untouched: its scalar setProperty path is not reachable through the value 
stack. Setting ids[0] on a Set is rejected by OGNL before it gets there, so no 
failing test could be written for it and it was not changed blind. Verified 
independently during review.

h2. Precedent

WW-3762 fixed this same bug class in XWorkBasicConverter.doConvertToCollection 
back in 2.3.3, and CollectionConverter still carries that guard today. The 
property accessors were simply never given the equivalent check.

WW-5701 is the mirror-image defect found while reviewing this fix: 
CollectionConverter's guard uses equals() and therefore drops a legitimate 
element whose text genuinely is the marker.

h2. Backward compatibility

Narrow. Previously an unconvertible entry was stored as the marker; now nothing 
is stored. Nothing can reasonably depend on the old behaviour, and the 
conversion error is reported either way, so validation-driven actions see no 
change at all.

h2. Status

Fixed in PR https://github.com/apache/struts/pull/1873 - three regression 
tests, written test-first and mutation-checked, plus an end-to-end test of the 
reported checkbox scenario. Full core suite green.


> Failed type conversion stores the NO_CONVERSION_POSSIBLE marker string into 
> typed Maps, Lists and Collections
> -------------------------------------------------------------------------------------------------------------
>
>                 Key: WW-5700
>                 URL: https://issues.apache.org/jira/browse/WW-5700
>             Project: Struts 2
>          Issue Type: Bug
>            Reporter: Lukasz Lenart
>            Assignee: Lukasz Lenart
>            Priority: Major
>          Time Spent: 20m
>  Remaining Estimate: 0h
>
> h2. Summary
> When conversion of a request parameter into a typed collection fails, Struts 
> stores its internal "conversion failed" marker into the collection instead of 
> skipping the assignment. The marker is itself a java.lang.String, so it lands 
> in a collection declared to hold some other type. Because generics are erased 
> at that point the store succeeds silently, and the ClassCastException is 
> deferred until application code reads the entry back.
> The resulting stack trace points at _application_ code rather than at Struts, 
> which makes this very hard to recognise from a bug report.
> Reported on the user list as "Struts setting a String object instead of 
> Integer in the form" (2026-08-14), against 7.2.1 and 7.3.0.
> h2. Root cause
> TypeConverter.java line 49 declares the marker as an Object field whose value 
> is the ordinary text "ognl.NoConversionPossible". XWorkConverter.convertValue 
> returns that marker on failure (lines 339, 351 and 361), _after_ correctly 
> registering the conversion error via handleConversionException.
> Three property accessors then store that return value with no guard:
> * XWorkMapPropertyAccessor.setProperty, around line 127 - both the key and 
> the value are unguarded
> * XWorkListPropertyAccessor.getRealValue, line 188
> * XWorkCollectionPropertyAccessor.getRealValue, line 263
> By contrast OGNL itself guards this correctly in OgnlRuntime at line 1323, 
> which is why a plain non-collection property is left untouched on a failed 
> conversion.
> h2. Reproduced
> On main at 05ad78a06, end-to-end through ParametersInterceptor with 
> annotation enforcement enabled. Both halves reproduce.
> _Value half_ - the reporter's case. An unchecked s:checkbox with 
> submitUnchecked="true" causes CheckboxInterceptor to submit the parameter 
> with its uncheckedValue, default "false". Bound into a HashMap with Long keys 
> and Integer values:
> {noformat}
> key=100 value=[1] (java.lang.Integer)
> key=200 value=[ognl.NoConversionPossible] (java.lang.String)
> conversionErrors={capDeferral[200]=ConversionData@...}
> {noformat}
> _Key half_ - the marker is stored as a map _key_, which breaks iteration over 
> the entire map rather than a single entry:
> {noformat}
> acceptable=[capDeferral['abc'], capDeferral[7]]
> key=[ognl.NoConversionPossible] (java.lang.String) value=[1]
> key=[7] (java.lang.Long) value=[2]
> conversionErrors={capDeferral['abc']=ConversionData@...}
> {noformat}
> The key half is reachable because the accepted-parameter-name patterns in 
> DefaultAcceptedPatternsChecker are asymmetric - the bare-bracket branch 
> accepts digits only, but the quoted-key branch accepts word characters:
> {noformat}
> (\[\d+])                            bare brackets: digits only
> (\['(\w-?|[\u4e00-\u9fa5]-?)+'])    quoted key: word characters
> {noformat}
> So {{capDeferral['abc']}} is an accepted parameter name even where the 
> declared map key type is numeric, and nothing downstream re-checks the key 
> against that type. This is worth stating explicitly because the natural "map 
> indices are numeric" intuition does not hold.
> Note the conversion error _is_ reported in both halves. This is therefore not 
> a validation bypass: it only bites an action that reads the collection 
> without acting on conversion errors.
> h2. Fix
> Guard for the marker and skip the store; the error has already been 
> registered by convertValue, so nothing is lost. In the map accessor the key 
> is guarded before the value is even converted, because a bad key poisons 
> iteration over the whole map rather than one entry. In the list accessor the 
> guard sits before the auto-grow block, so an unconvertible value does not 
> grow the list.
> The comparison is by reference rather than equals(). That is correct rather 
> than incidental: the marker field is declared Object, not String, so it is 
> not a JLS constant variable and is not inlined into referencing class files - 
> every reference resolves to the one field value at runtime, third-party 
> converters included. A parameter value built by a servlet container from 
> request bytes is a distinct object, so reference comparison separates "the 
> converter signalled failure" from "the user submitted this text".
> To be precise about the limit: this protects values arriving from a request, 
> which is the case that matters here. It does not protect a value that happens 
> to be interned, since all identical String literals share one instance - 
> application code calling the converter programmatically with such a literal 
> would still lose it. Closing that as well would mean giving the marker an 
> identity no user string can share, which changes a published constant and is 
> a binary-compatibility question rather than a bug fix.
> h2. Not included
> XWorkCollectionPropertyAccessor carries the same unguarded pattern but is 
> left untouched: its scalar setProperty path is not reachable through the 
> value stack. Setting {{ids[0]}} on a Set is rejected by OGNL before it gets 
> there, so no failing test could be written for it and it was not changed 
> blind. Verified independently during review.
> h2. Precedent
> WW-3762 fixed this same bug class in 
> XWorkBasicConverter.doConvertToCollection back in 2.3.3, and 
> CollectionConverter still carries that guard today. The property accessors 
> were simply never given the equivalent check.
> WW-5701 is the mirror-image defect found while reviewing this fix: 
> CollectionConverter's guard uses equals() and therefore drops a legitimate 
> element whose text genuinely is the marker.
> h2. Backward compatibility
> Narrow. Previously an unconvertible entry was stored as the marker; now 
> nothing is stored. Nothing can reasonably depend on the old behaviour, and 
> the conversion error is reported either way, so validation-driven actions see 
> no change at all.
> h2. Status
> Fixed in PR https://github.com/apache/struts/pull/1873 - three regression 
> tests, written test-first and mutation-checked, plus an end-to-end test of 
> the reported checkbox scenario. Full core suite green.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to