You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/04 20:46:28 UTC
(juneau) branch master updated: WORK-J0517: Extend SANITIZED_HTML real-DOM/browser coverage for entity decoding, table foster-parenting, template/noscript, and namespace/foreign-content (SVG) copier behavior.
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new cc35acd8c6 WORK-J0517: Extend SANITIZED_HTML real-DOM/browser coverage for entity decoding, table foster-parenting, template/noscript, and namespace/foreign-content (SVG) copier behavior.
cc35acd8c6 is described below
commit cc35acd8c6ca7bf37fcfbcb3583e2c18641fdaca
Author: James Bognar <ja...@apache.org>
AuthorDate: Fri Sep 4 16:37:10 2026 -0400
WORK-J0517: Extend SANITIZED_HTML real-DOM/browser coverage for entity decoding, table foster-parenting, template/noscript, and namespace/foreign-content (SVG) copier behavior.
---
.../server/views/SanitizedHtml_BrowserTest.java | 103 ++++++++++++++++++
.../src/test/js/sanitized-html-browser.cjs | 116 +++++++++++++++++++++
2 files changed, 219 insertions(+)
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SanitizedHtml_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SanitizedHtml_BrowserTest.java
index a743fe6123..bfd001bd68 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SanitizedHtml_BrowserTest.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SanitizedHtml_BrowserTest.java
@@ -124,4 +124,107 @@ class SanitizedHtml_BrowserTest extends TestBase {
assertEquals(Boolean.FALSE, report.get("okTextHasMarkup"),
() -> "markup must be real elements, not literal angle brackets in the text layer: " + report);
}
+
+ // ---------------------------------------------------------------------------------------------------
+ // WORK-J0517: entity decoding - a regex shim cannot decode "<script>" the way a real HTML
+ // parser does, so this proves the decoded text stays literal text and is never re-parsed as markup.
+ // ---------------------------------------------------------------------------------------------------
+
+ @Test void a04_entityEncodedScript_decodesToLiteralText_notReparsedToElement() {
+ assertEquals(Boolean.TRUE, report.get("entityNoScriptElement"),
+ () -> "an entity-encoded <script> must never decode into a real <script> element: " + report);
+ assertEquals(Boolean.TRUE, report.get("entityPSurvived"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("entityTextIsLiteral"),
+ () -> "decoded text must contain the literal '<script>alert(1)</script>' characters: " + report);
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // WORK-J0517: <table> foster-parenting - the HTML5 tree builder relocates a misplaced non-table-
+ // structure child to a PRECEDING SIBLING of the table before the copier ever walks the tree. a05
+ // proves that is real-parser ground truth (not a harness assumption); a06 proves the copier's output
+ // mirrors that already-fostered shape rather than assuming a naive nested read.
+ // ---------------------------------------------------------------------------------------------------
+
+ @Test void a05_tableForeignChild_fosterParentedBeforeTable_byRealParser() {
+ assertEquals(Boolean.TRUE, report.get("fosterSrcBBeforeTable"),
+ () -> "real-parser ground truth: <b> must be foster-parented to precede <table>, order was "
+ + report.get("fosterSrcOrder"));
+ }
+
+ @Test void a06_copierMirrorsFosteredTreeShape_notNaiveNestedRead() {
+ assertEquals(Boolean.TRUE, report.get("fosterCopyBBeforeTable"),
+ () -> "copier output must mirror the fostered order, was " + report.get("fosterCopyOrder"));
+ assertEquals(Boolean.TRUE, report.get("fosterCopyBNotNestedInTable"),
+ () -> "the foster-parented <b> must not appear nested inside <table> in the copy: " + report);
+ assertEquals(Boolean.TRUE, report.get("fosterCopyCellSurvived"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("fosterCopySpanSurvived"), () -> report.toString());
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // WORK-J0517: <template> - content lives in an inert DocumentFragment, never the element's own
+ // childNodes (real-parser property a regex shim's substring view cannot model). a07 proves that
+ // ground truth; a08 proves the copier drops the (DROP_TAGS) tag and its content wholesale.
+ // ---------------------------------------------------------------------------------------------------
+
+ @Test void a07_templateContent_livesInInertFragment_neverOwnChildNodes_realParser() {
+ assertEquals(Boolean.TRUE, report.get("templateOwnChildNodesEmpty"),
+ () -> "a <template>'s own childNodes must be empty; content lives in .content instead: " + report);
+ assertEquals(Boolean.TRUE, report.get("templateContentHasChildren"),
+ () -> "the inert .content fragment must still hold the parsed children: " + report);
+ }
+
+ @Test void a08_templateAndContents_droppedWholesale_noExecutionNoLeak() {
+ assertEquals(Boolean.TRUE, report.get("templateNotExecuted"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("templateNoTemplateTag"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("templateNoScriptTag"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("templateHiddenTextNotLeaked"),
+ () -> "template content must not leak into the copy's text layer: " + report);
+ assertEquals(Boolean.TRUE, report.get("templateSiblingSurvived"), () -> report.toString());
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // WORK-J0517: <noscript> - a DOMParser document has no browsing context, so scripting is DISABLED,
+ // which per spec means <noscript> content parses as REAL child elements (opposite of a scripting-
+ // enabled page's raw-text treatment). a09 proves that ground truth; a10 proves the copier drops the
+ // (DROP_TAGS) tag and its now-real-element children wholesale regardless.
+ // ---------------------------------------------------------------------------------------------------
+
+ @Test void a09_noscriptContent_parsedAsRealElements_scriptingDisabled_realParser() {
+ assertEquals(Boolean.TRUE, report.get("noscriptParsedAsRealElements"),
+ () -> "with scripting disabled (a DOMParser doc), <noscript> content must parse as real "
+ + "elements, not raw text: " + report);
+ }
+
+ @Test void a10_noscriptAndContents_droppedWholesale_noExecutionNoLeak() {
+ assertEquals(Boolean.TRUE, report.get("noscriptNotExecuted"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("noscriptNoScriptTag"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("noscriptHiddenTextNotLeaked"),
+ () -> "noscript content must not leak into the copy's text layer: " + report);
+ assertEquals(Boolean.TRUE, report.get("noscriptSiblingSurvived"), () -> report.toString());
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // WORK-J0517: namespace / foreign content - <svg> switches the HTML5 tree builder into the
+ // foreign-content algorithm, producing REAL SVG-namespace nodes. a11 proves that ground truth; a12
+ // proves the (DROP_TAGS) tag-name-only allowlist match drops the whole foreign subtree wholesale,
+ // never smuggling a same-named allowed HTML tag (e.g. an SVG-namespace <a>) through by namespace-blind
+ // matching.
+ // ---------------------------------------------------------------------------------------------------
+
+ @Test void a11_svgForeignContent_isRealForeignNamespace_realParser() {
+ assertEquals(Boolean.TRUE, report.get("svgIsRealForeignNamespace"),
+ () -> "<svg> must parse into a real SVG-namespace element: " + report);
+ assertEquals(Boolean.TRUE, report.get("svgScriptIsForeignNamespace"),
+ () -> "the nested <script> inside <svg> must be a real SVG-namespace node: " + report);
+ }
+
+ @Test void a12_svgSubtree_droppedWholesale_noScriptNoAnchorSmuggled() {
+ assertEquals(Boolean.TRUE, report.get("svgNotExecuted"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("svgNoScriptTag"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("svgNoAnchorSmuggled"),
+ () -> "an SVG-namespace <a> must not be smuggled through as an allowed HTML <a>: " + report);
+ assertEquals(Boolean.TRUE, report.get("svgClickTextNotLeaked"),
+ () -> "the dropped foreign subtree's text must not leak into the copy: " + report);
+ assertEquals(Boolean.TRUE, report.get("svgSiblingSurvived"), () -> report.toString());
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/sanitized-html-browser.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/sanitized-html-browser.cjs
index 808b96eed3..94233f7789 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/sanitized-html-browser.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/sanitized-html-browser.cjs
@@ -30,6 +30,21 @@
* Loads the REAL served juneau-views.js in headless Chromium and asserts that a hostile expand-JSON
* SANITIZED_HTML value is copied through the allowlist (no script/handler executes), while benign markup
* (a <b> and a <table>) survives as real elements.
+ *
+ * WORK-J0517 extends this file (beyond the WORK-J0515 XSS/benign smoke pair above) with four more
+ * real-browser-parser behaviors a regex shim cannot prove:
+ * - HTML entity decoding (decoded text must stay text, never re-parsed into an element).
+ * - <table> foster-parenting (misplaced non-table-structure content is relocated by the HTML5 tree
+ * builder BEFORE the copier ever walks the tree; each "b0N_..." pair below first inspects the real
+ * parser's own output as ground truth, then checks the copier mirrors that shape rather than assuming
+ * a naive nested read).
+ * - <template> (content lives in an inert DocumentFragment, never the element's own childNodes) and
+ * <noscript> (parsed as REAL child elements, not raw text, because a DOMParser document has no
+ * browsing context and therefore scripting disabled - the opposite of what a scripting-enabled page
+ * would do) - both are SANITIZED_HTML_DROP_TAGS, so both must vanish (tag AND children) regardless.
+ * - Namespace/foreign content (<svg><script>...) - the foreign-content algorithm parses real SVG-namespace
+ * nodes; the copier's tag-name-only allowlist match must not smuggle a foreign-namespace node through
+ * just because its local name collides with an allowed HTML tag (e.g. an SVG-namespace <a>).
*/
'use strict';
@@ -69,6 +84,107 @@ const PROBE = async function () {
out.okTextHasWorld = slot.textContent.indexOf('world') >= 0;
out.okTextHasMarkup = slot.textContent.indexOf('<b>') >= 0;
+ // -------------------------------------------------------------------------------------------------
+ // WORK-J0517: entity decoding - "<script>" in text content must decode to LITERAL text
+ // ("<script>alert(1)</script>" as characters), never be re-parsed into an actual element. The
+ // copier cannot "re-parse" this even in principle (it copies the already-decoded text-node value via
+ // createTextNode), but the regex shim's naive substring view could plausibly get this wrong, so this
+ // proves the real-parser + real-copier pairing behaves as intended end to end.
+ // -------------------------------------------------------------------------------------------------
+ const ENTITY = '<p><script>alert(1)</script></p>';
+ I.fillSanitizedHtmlSlot(slot, ENTITY);
+ out.entityNoScriptElement = slot.querySelectorAll('script').length === 0;
+ out.entityPSurvived = slot.querySelectorAll('p').length === 1;
+ out.entityTextIsLiteral = slot.textContent.indexOf('<script>alert(1)</script>') >= 0;
+
+ // -------------------------------------------------------------------------------------------------
+ // WORK-J0517: <table> foster-parenting. A non-table-structure element opened directly inside <table>
+ // (before any <tr>) is relocated ("foster-parented") by the HTML5 tree builder to become a PRECEDING
+ // SIBLING of the table, not a descendant - this happens during parsing, before the copier ever sees
+ // the tree. First capture the real parser's own ground-truth shape (mirroring the module's own
+ // parseSanitizedHtmlWrap: `new DOMParser().parseFromString("<div>"+html+"</div>", "text/html")`),
+ // then check the copier's output mirrors that already-fostered shape (not a naive nested read that
+ // would expect <b> nested inside <table>).
+ // -------------------------------------------------------------------------------------------------
+ const FOSTER = '<table><b>oops</b><tr><td>cell</td></tr></table><span>after</span>';
+ const fosterSrcWrap = new DOMParser().parseFromString('<div>' + FOSTER + '</div>', 'text/html').body.firstChild;
+ const fosterSrcTags = Array.from(fosterSrcWrap.children).map(e => e.tagName);
+ out.fosterSrcOrder = fosterSrcTags;
+ out.fosterSrcBBeforeTable = fosterSrcTags.indexOf('B') >= 0 && fosterSrcTags.indexOf('B') < fosterSrcTags.indexOf('TABLE');
+
+ I.fillSanitizedHtmlSlot(slot, FOSTER);
+ out.fosterCopyOrder = Array.from(slot.children).map(e => e.tagName);
+ out.fosterCopyBBeforeTable = out.fosterCopyOrder.indexOf('B') >= 0
+ && out.fosterCopyOrder.indexOf('B') < out.fosterCopyOrder.indexOf('TABLE');
+ out.fosterCopyBNotNestedInTable = slot.querySelector('table b') === null;
+ out.fosterCopyCellSurvived = (() => {
+ const td = slot.querySelector('table td');
+ return !!td && td.textContent === 'cell';
+ })();
+ out.fosterCopySpanSurvived = slot.textContent.indexOf('after') >= 0;
+
+ // -------------------------------------------------------------------------------------------------
+ // WORK-J0517: <template> - its content lives in an INERT DocumentFragment (`.content`), never in the
+ // element's own `.childNodes` - a real-parser property the regex shim's substring view cannot model.
+ // TEMPLATE is a SANITIZED_HTML_DROP_TAGS entry, so the copier must drop the tag AND its content
+ // wholesale (never execute the nested <script>, never leak the nested <b> text) while a sibling
+ // element still survives.
+ // -------------------------------------------------------------------------------------------------
+ window.__juneauSanitizedHtmlTplExec = 0;
+ const TPL = '<template><script>window.__juneauSanitizedHtmlTplExec=1</script><b>hiddentplchild</b></template><span>aftertpl</span>';
+ const tplSrcEl = new DOMParser().parseFromString('<div>' + TPL + '</div>', 'text/html').body.firstChild.querySelector('template');
+ out.templateOwnChildNodesEmpty = !!tplSrcEl && tplSrcEl.childNodes.length === 0;
+ out.templateContentHasChildren = !!tplSrcEl && !!tplSrcEl.content && tplSrcEl.content.childNodes.length > 0;
+
+ I.fillSanitizedHtmlSlot(slot, TPL);
+ out.templateNotExecuted = window.__juneauSanitizedHtmlTplExec === 0;
+ out.templateNoTemplateTag = slot.querySelectorAll('template').length === 0;
+ out.templateNoScriptTag = slot.querySelectorAll('script').length === 0;
+ out.templateHiddenTextNotLeaked = slot.textContent.indexOf('hiddentplchild') < 0;
+ out.templateSiblingSurvived = slot.textContent.indexOf('aftertpl') >= 0;
+
+ // -------------------------------------------------------------------------------------------------
+ // WORK-J0517: <noscript> - a DOMParser-created document has no browsing context, so scripting is
+ // DISABLED, and per the HTML5 spec that means <noscript> content is parsed as REAL child elements
+ // (not raw text the way a scripting-ENABLED page would treat it) - the opposite of the naive
+ // assumption. NOSCRIPT is also a SANITIZED_HTML_DROP_TAGS entry, so regardless of that real-element
+ // parse, the copier must drop the tag and its (now-real-element) children wholesale.
+ // -------------------------------------------------------------------------------------------------
+ window.__juneauSanitizedHtmlNsExec = 0;
+ const NOS = '<noscript><script>window.__juneauSanitizedHtmlNsExec=1</script><i>hiddensnschild</i></noscript><span>afterns</span>';
+ const nsSrcEl = new DOMParser().parseFromString('<div>' + NOS + '</div>', 'text/html').body.firstChild.querySelector('noscript');
+ out.noscriptParsedAsRealElements = !!nsSrcEl && !!nsSrcEl.firstElementChild
+ && nsSrcEl.firstElementChild.tagName === 'SCRIPT';
+
+ I.fillSanitizedHtmlSlot(slot, NOS);
+ out.noscriptNotExecuted = window.__juneauSanitizedHtmlNsExec === 0;
+ out.noscriptNoScriptTag = slot.querySelectorAll('script').length === 0;
+ out.noscriptHiddenTextNotLeaked = slot.textContent.indexOf('hiddensnschild') < 0;
+ out.noscriptSiblingSurvived = slot.textContent.indexOf('afterns') >= 0;
+
+ // -------------------------------------------------------------------------------------------------
+ // WORK-J0517: namespace / foreign content. <svg> switches the HTML5 tree builder into the
+ // foreign-content algorithm, producing REAL SVG-namespace nodes (including an SVG-namespace <script>
+ // and <a>, both distinct nodes from their HTML-namespace counterparts even though `tagName` collides
+ // case-insensitively). SVG is a SANITIZED_HTML_DROP_TAGS entry so the whole foreign subtree - script,
+ // nested <a href="javascript:...">, and its text - must be dropped wholesale by tag-name match alone,
+ // never partially unwrapped/smuggled through by virtue of a same-named allowed HTML tag underneath.
+ // -------------------------------------------------------------------------------------------------
+ window.__juneauSanitizedHtmlSvgExec = 0;
+ const SVGX = '<svg><script>window.__juneauSanitizedHtmlSvgExec=1</script><a href="javascript:alert(1)">clickme</a></svg><span>aftersvg</span>';
+ const svgSrcEl = new DOMParser().parseFromString('<div>' + SVGX + '</div>', 'text/html').body.firstChild.querySelector('svg');
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ out.svgIsRealForeignNamespace = !!svgSrcEl && svgSrcEl.namespaceURI === SVG_NS;
+ out.svgScriptIsForeignNamespace = !!svgSrcEl && !!svgSrcEl.querySelector('script')
+ && svgSrcEl.querySelector('script').namespaceURI === SVG_NS;
+
+ I.fillSanitizedHtmlSlot(slot, SVGX);
+ out.svgNotExecuted = window.__juneauSanitizedHtmlSvgExec === 0;
+ out.svgNoScriptTag = slot.querySelectorAll('script').length === 0;
+ out.svgNoAnchorSmuggled = slot.querySelectorAll('a').length === 0;
+ out.svgClickTextNotLeaked = slot.textContent.indexOf('clickme') < 0;
+ out.svgSiblingSurvived = slot.textContent.indexOf('aftersvg') >= 0;
+
return out;
};