Repository navigation
WW-5772 Remove inline event handlers from optiontransferselect, inputtransferselect, updownselect and combobox - #2012
Conversation
…inline onclick The optiontransferselect, inputtransferselect and updownselect tags rendered every button with a framework-generated onclick handler that built a JavaScript call from the select ids and the headerKey / doubleHeaderKey tag attributes. Pages using these tags therefore needed 'unsafe-inline' in their Content-Security-Policy. Each button now carries its action, target select ids and header keys in data-* attributes, and optiontransferselect.js / inputtransferselect.js register one delegated click listener per page (capture phase) that calls the same functions with the same arguments as before. A developer-supplied *Onclick attribute on optiontransferselect is still rendered as onclick and still runs after the framework action. The rendered markup now depends on the matching static JS: an application that serves its own copy of optiontransferselect.js or inputtransferselect.js through struts.ui.staticContentPath must update it together with the templates. Generated-by: an AI coding assistant; reviewed and tested by the author.
The combobox tag (simple and html5 themes) rendered its select with an inline onChange="autoPopulate_<id>(this)" handler. The select now carries data-struts-combobox="<escapedId>" and the handler is attached with addEventListener inside an <@s.script> block, which carries the CSP nonce like the autoPopulate function the template already emits. The script binds every select with its marker that is not bound yet, so comboboxes repeated with the same generated id, for example inside an iterator, each get exactly one handler, as they did with the inline attribute. It looks the selects up instead of using document.currentScript, so it also works when the fragment is loaded over AJAX and its scripts are evaluated after insertion (jQuery .html() evaluates them through a temporary script element in <head>). Generated-by: an AI coding assistant; reviewed and tested by the author.
lukaszlenart
left a comment
There was a problem hiding this comment.
Thanks for this, moving the framework-built handlers out of the markup is a good direction. A few points below. None of them block this PR; I've filed the larger ones as follow-up tickets so they don't need to be folded in here:
| @@ -22,6 +22,18 @@ | |||
| <@s.script src="${base}${attributes.staticContentPath}/optiontransferselect.js" /> | |||
| <#assign temporaryVariable = stack.setValue("#optiontransferselect_js_included", "true") /><#t/> | |||
There was a problem hiding this comment.
The new markup only works with the new optiontransferselect.js, but this URL isn't versioned. A browser holding the cached old script (static content is cached for a day), or an app serving its own copy, renders buttons with no onclick and no listener, so clicks silently do nothing. Same for inputtransferselect.ftl and updownselect.ftl.
The upgrade note in WW-5772 covers it for now; a cache-busting parameter on these URLs is tracked separately in WW-5773.
| </option><#lt/> | ||
| </@s.iterator> | ||
| </select> | ||
| <@s.script> |
There was a problem hiding this comment.
Combobox takes a different route from the transfer selects: a per-instance inline script (with the nonce) rather than a static delegated listener. That works under a nonce-based CSP, but:
- a hash-based or static-scripts-only policy still blocks it,
- this block is duplicated verbatim in
html5/combobox.ftl, - N comboboxes in an iterator emit N blocks, each running
querySelectorAllover the page.
Fine to land as is; a static combobox.js with one delegated change listener is tracked in WW-5774.
| window.strutsOptionTransferSelectListener = true; | ||
|
|
||
| function headerKey(button, name) { | ||
| return button.hasAttribute(name) ? button.getAttribute(name) : undefined; |
There was a problem hiding this comment.
Not introduced here, but worth knowing since the ticket says a header key with a quote now works: the key still goes through compile(), which builds new RegExp("^" + ptn + "$") without escaping. A key like [none makes every button throw Invalid regular expression, and (none) doesn't match itself, so the header option gets moved/selected with the rest. The old inline handlers had the same behaviour, so no change needed in this PR.
| strutsBodyTagsAreReflectionEqual(tag, freshTag)); | ||
| } | ||
|
|
||
| public void testButtonsCarryHeaderKeysAsDataAttributesWithoutInlineHandlers() throws Exception { |
There was a problem hiding this comment.
Nice targeted test. There's no equivalent for inputtransferselect (data-struts-header-key on move up/down) or for combobox (no inline onChange, marker attribute, nonce on the binding script), so regressions there are only caught by the fixture files. Tracked in WW-5775.
Fixes WW-5772
Problem
The
optiontransferselect,inputtransferselectandupdownselecttags render every button with anonclickhandler that the framework builds itself. Thecomboboxtag renders its select with an inlineonChange. An application that wants a nonce-based Content-Security-Policy without'unsafe-inline'therefore cannot use these tags, even though<s:script>already applies the nonce to the scripts the same templates emit.The generated
onclickcode also contains theheaderKey/doubleHeaderKeyvalues as JavaScript string literals that are only HTML-escaped. The browser decodes'back to'before it runs the handler, so a header key that contains a quote breaks the handler.Approach
data-struts-*attributes, which FreeMarker HTML-escapes.optiontransferselect.js(also used by updownselect) andinputtransferselect.jseach register one delegated click listener per page, in the capture phase, guarded against double registration. The listener calls the same existing functions with the same arguments as the old inline handlers, header-key/no-header-key branches included.optiontransferselect.ftlfolds its duplicated per-button branches into onetransferButtonmacro, about 100 lines shorter. A developer-supplied*Onclickattribute is still rendered asonclickand still runs after the framework action.data-struts-combobox="<escapedId>". Thechangelistener is attached from an<@s.script>block, which carries the nonce like theautoPopulate_<id>function the template already emits. The script binds every select with that marker that is not bound yet. Repeated comboboxes with the same id (e.g. inside an iterator) therefore each get exactly one handler, and fragments loaded over AJAX work too: jQuery.html()evaluates scripts through a temporary element in<head>, sodocument.currentScriptcannot be used.Why it is systematic
Each of these tags had the same pattern: framework-built JavaScript in event-handler attributes. The patch removes that pattern from them entirely rather than escaping individual values, so neither the CSP blocker nor the quoting problem can come back through these templates.
Upgrade note (please add to the release notes / migration guide)
The new markup needs the new
optiontransferselect.jsandinputtransferselect.js.struts.serve.static=falseor a customstruts.ui.staticContentPath) must update those two files together with the templates. Otherwise the transfer buttons silently do nothing.button.onclick()directly no longer triggers the framework action. Real clicks andelement.click()still do.Tests
./mvnw -pl core teston JDK 17: 3466 tests, 0 failures, 0 errors, 0 skipped.OptionTransferSelectTagTestandUpDownSelectTagTestcheck that framework buttons render noonclickand that a header key containing a quote (it's-left) lands HTML-escaped in adata-*attribute. The rendering fixtures are updated. On the base templates, 51 of the 57 tests in the four affected tag test classes fail.<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-…'">with no'unsafe-inline': zero CSP violations. These all worked:innerHTMLand its scripts evaluated jQuery-style, which populated its field.Follow-ups (separate tickets; this PR does not make the themes fully CSP-clean)
form.ftl: defaultonreset="clearErrorMessages…"and theonsubmit="return validateForm_…"for client-side validationtooltip.ftl:domTT_activateonmouseoverwhenjavascriptTooltipis ondoubleselect: theonchange="<id>Redirect(…)"built inDoubleSelect.javaThis change was prepared with AI assistance and reviewed and tested by me.