Remove the Java serialization round trip from the bundled Click HiddenField - #1109
Conversation
…he bundled Click HiddenField ClickUtils.decode(String) ran Base64, then GZIP, then ObjectInputStream.readObject() over its argument with no ObjectInputFilter, no class allowlist and no depth or size limit. Its only caller was HiddenField.bindRequestValue(), which reached it whenever the field's value class was a Serializable other than the handful the method parses explicitly - and the argument in that path is a request parameter. ClickUtils.encode produced the format decode read, and its only caller was HiddenField.render(). No page in the product binds such a field: nothing outside the Click framework calls addControl, the only Click HiddenField instances are the framework's own NonProcessedHiddenField over String and Long, and the pages the Click servlet serves are the configurator and upgrader, which create no controls. The sink therefore has no legitimate caller to protect, so it is removed rather than filtered: an ObjectInputFilter would leave a live readObject() behind a configuration, whereas deleting a closed round trip that nothing else produces or consumes cannot regress anything. A submission to a field declared with an unsupported value class now takes the same path every other unrecognised class already took - setValue(aValue), which refuses it because a String is not of the declared value class. The test builds the exact payload the removed encode() produced, wrapping a canary whose readObject records that it ran, and asserts the canary stays untouched when the field binds it. Against the previous code that test fails with the canary deserialized. com.sun.identity.config.util.TemplatedForm, which extended the upstream Click Form and was referenced from nowhere, is dropped at the same time: it was the one place from which a Click form could plausibly have been introduced. Reported by GitHub @leanworld7-netizen, and independently twice more.
maximthomas
left a comment
There was a problem hiding this comment.
Verified the reachability argument independently and it holds: no addControl caller outside the Click fork, the only HiddenField instances are NonProcessedHiddenField over String/Long (skipped during binding), TemplatedForm is unreferenced repo-wide including non-Java artifacts, and only the fork's Jakarta ClickServlet is mapped in any web.xml. The removal compiles cleanly — close() and the GZIPOutputStream import survive where still used, and no dangling {@link ClickUtils#encode/decode} javadoc is left. The canary test genuinely fails against the pre-change code.
Two things below; none is a defect in the behaviour being shipped.
The canary tests pass on any IllegalArgumentException, including one thrown before the code under test runs (Minor)
openam-core/src/test/java/org/openidentityplatform/openam/click/control/HiddenFieldTest.java:83-88 and :104-108:
try {
field.bindRequestValue();
} catch (IllegalArgumentException expected) {
}
assertFalse(Canary.deserialized, "the submitted serialization stream must not be deserialized");Canary.deserialized stays false whenever bindRequestValue() fails to reach the branch under test, not only when it reaches it and refuses — so the pair is satisfied by a bind that never happened. Add argument validation earlier in bindRequestValue() that throws IAE, or soften the unsupported-class path to logger.warn(...); return;, and the test goes green while testing nothing. The path is correct today (setValue(aValue) → setValueObject(String) → IAE at HiddenField.java:233-240), but nothing pins it.
One-line fix — TestNG 6.14.3 (pom.xml:1691-1693) has Assert.assertThrows:
assertThrows(IllegalArgumentException.class, field::bindRequestValue);
assertFalse(Canary.deserialized, "the submitted serialization stream must not be deserialized");render() is changed by this PR and has no test coverage (Minor)
The diff deletes render()'s Serializable arm, which used the unescaped buffer.appendAttribute(...); such a value now falls to appendAttributeEscaped("value", getValue()) at openam-core/src/main/java/org/openidentityplatform/openam/click/control/HiddenField.java:358. The PR's own argument is "escaped rather than raw, which is also the safer of the two" — so that call is now load-bearing, and no test touches render(). HiddenFieldTest.java is the only test file under openam-core/src/test/java/org/openidentityplatform/openam/click/, so there is no prior coverage either.
For a String value class the chain is request parameter → setValue(aValue) (:271-272) → getValue() = getValueObject().toString() (:182-184) → render, with that one escaping call in between. Swapping it back to appendAttribute — the exact form the deleted branch used — would be reflected XSS with the suite green.
Cheap to pin while the fixture is open:
@Test
public void renderEscapesTheValueAttribute() {
HiddenField text = new HiddenField("text", String.class);
text.setValue("\"><script>alert(1)</script>");
HtmlStringBuffer buffer = new HtmlStringBuffer();
text.render(buffer);
assertFalse(buffer.toString().contains("<script>"), "the value attribute must be escaped");
}A Long-classed field asserting value="42" and a Date-classed one asserting epoch millis (HiddenField.java:353-355) would cover the rest of the branch.
Nits
.htmtemplate count: the PR body says "13.htmtemplates and all of them live under/config/**". It looks like 11 are under/config/**;/click/error.htmand/click/not-found.htmare the framework's defaultErrorPage/Pagebindings. Neither declares controls, so the conclusion is unaffected — but a future auditor re-running the argument will hit the discrepancy.- Changelog entry:
ClickUtils.encode(Object),ClickUtils.decode(String)andcom.sun.identity.config.util.TemplatedFormare allpublicin the publishedopenam-core. Removal is the right call, but it is binary-incompatible and belongs in the release notes rather than being found by a downstream build break. - Redundant assertion:
HiddenFieldTest.java:92-93—assertFalse(field.getValueObject() instanceof Canary, ...)is always true post-change, since the IAE fires beforevalueObjectis assigned. It does have signal against the pre-change code, so it is not wrong, just weaker than it reads. - Out of scope, spotted while verifying:
openam-core/src/main/java/org/openidentityplatform/openam/click/control/Form.java:3061testscontrol instanceof org.apache.click.control.Container— the upstream interface, which no fork control implements — soaddStatefulFieldsnever recurses into child containers. Harmless today (noFormis ever constructed) and unrelated to this PR; worth a separate issue.
…enFieldTest The canary tests swallowed the IllegalArgumentException, so their assertion was also satisfied by a bind that never reached the branch under test: any future validation added earlier in bindRequestValue(), or a softening of the refusal to a log-and-return, would leave them green while testing nothing. They now assert the refusal itself, and that nothing was assigned to the field. render() is moved by this change and had no coverage. The branch the deleted Serializable arm used to intercept now lands on appendAttributeEscaped(), so a value class the control does not parse is rendered and its value attribute asserted escaped there, with String, Long and Date covering the rest. Four of the seven cases now fail against the pre-change sources, up from three: the added render case emits the raw Base64 serialization stream unescaped.
|
Thanks — both points taken. Pushed as 3a5a950, test file only; the source diff under review is unchanged. 1. The canary tests pass on any
|
|
The |
|
Follow-up on the reachability argument you re-verified: it is stronger than the PR body claimed, so I have tightened that paragraph. The upstream The |
|
Closing in favour of #1062, which removes the whole Apache Click fork rather than the one sink inside it. All three source files this PR touched are deleted there — GHSA-7j4m-m698-57hp should now track #1062 instead of this PR. The advisory stays open until that one merges; it is approved and mergeable, with a manual wizard walkthrough outstanding. Worth keeping from here for whoever audits the advisory:
Two issues came out of the sweep done while reviewing this PR and are unaffected by the close: #1110 and #1111, both of which #1062 also resolves. |
Removes an unfiltered Java deserialization sink from the bundled Apache Click fork, together with the encoder that fed it.
What was there
ClickUtils.decode(String)performed Base64 → GZIP →ObjectInputStream.readObject()with noObjectInputFilter, no class allowlist and no depth or size limit. Its only caller wasHiddenField.bindRequestValue(), which reached it whenever the field's value class was aSerializableother than the handful the method parses explicitly — and in that path the argument is a request parameter.ClickUtils.encode(Object)produced the formatdecoderead, and its only caller wasHiddenField.render().Why removal rather than an ObjectInputFilter
The sink has no legitimate caller to protect:
addControl(...)anywhere in the product;HiddenFieldinstances are the framework's ownNonProcessedHiddenFieldoverStringandLong(Form.java:1321,:2391), andNonProcessedHiddenFieldis skipped during binding;com.sun.identity.config.*); the deployment ships 13.htmtemplates — 11 under/config/**, plus the framework's own/click/error.htmand/click/not-found.htm, which are the defaultErrorPage/Pagebindings. None of them declares a control, and the configurator pages read request parameters directly;ClickServlet.processPageRequestParams()binds parameters via OGNL to declared page fields, restricted to primitives /String/Number/Booleanunless a customTypeConverteris installed — none is registered anywhere in the product.encodeanddecodetherefore form a closed round trip insideHiddenFieldthat nothing else produces or consumes. AnObjectInputFilterwould leave a livereadObject()guarded by configuration; deleting the pair cannot regress anything.com.sun.identity.config.util.TemplatedFormgoes at the same time. It extended the upstream ClickForm, was referenced from nowhere, and was the one place from which a Click form could plausibly have been introduced.Behaviour
A submission to a field declared with an unsupported value class now takes the path every other unrecognised value class already took —
setValue(aValue), which refuses it because aStringis not of the declared value class. No shipped page declares such a field.The
render()side loses itsSerializablebranch as well, so such a value falls to the existingappendAttributeEscaped("value", getValue())atHiddenField.java:358— escaped rather than raw, which is also the safer of the two.Tests
HiddenFieldTestbuilds the exact payload the removedencode()produced, wrapping a canary whosereadObjectrecords that it ran, then asserts that the bind is refused with anIllegalArgumentException, that the canary stays untouched and that nothing was assigned to the field — for a concrete value class and forSerializableitself. Asserting the refusal rather than swallowing it matters: otherwise the canary assertion would also be satisfied by a bind that never reached the branch under test.Two more cases check that
encode/decodehave not come back, and thatString,Long,Booleanand an empty submission still bind as before.Because the diff also moves
render(), that side is pinned too. TheHiddenField.java:358call is the branch the deletedSerializablearm used to intercept, so a value class the control does not parse is rendered and thevalueattribute asserted escaped; the same for aString, withLongandDatecovering the remaining two branches.The tests were verified against the previous code by temporarily reverting the two source files. Four of the seven fail there: the canary is deserialized, the bind is not refused,
ClickUtils.decode(String)is still present, andrender()emits the raw serialization stream —value="H4sIAAAAAAAA/1vzloG1uIjBM78oXS+/IDUvMyU1rySzpLIgJ7EkLb8oFyyYmKuXnJOZnK2XnJ9XUpSfo+eRmQJU55aZmpMSklpcohKaV1xaUJBfVJKawgABjEwMDBUFAH5jUwJeAAAA"— unescaped. They are regression tests rather than a description of new behaviour.Not covered here
The upstream
org.apache.click:click-nodeps:2.3.0jar remains a dependency ofopenam-coreand carries its own copy of the samedecode. Nothing can reach it. Its only caller there is the upstreamHiddenField, which is driven by the upstreamorg.apache.click.ClickServlet— not mapped in either deployment descriptor, only the fork's servlet is. That servlet could not even be loaded if it were mapped: it extendsjavax.servlet.http.HttpServlet, and that class is absent from the deployment. Both modules resolve onlyjakarta.servlet-api-5.0.0, and none of the 381 jars on theopenam-server-onlyclasspath carriesjavax/servlet/http/HttpServlet.class, so loadingorg.apache.click.ClickServletfails on superclass resolution.Dropping the dependency is still a separate piece of work: the fork imports 32 distinct classes from that jar and does not compile without it.
ClickUtils.encode(Object),ClickUtils.decode(String)andcom.sun.identity.config.util.TemplatedFormarepublicin the publishedopenam-core, so removing them is binary-incompatible for anything downstream that reached for them. The repository carries no changelog file; this belongs in the GitHub release notes of the version that ships it.