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 "&lt;script&gt;" 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 - "&lt;script&gt;" 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>&lt;script&gt;alert(1)&lt;/script&gt;</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;
 };