diff --git a/content/karma.conf.js b/content/karma.conf.js index c291077dd6..132f7190d1 100644 --- a/content/karma.conf.js +++ b/content/karma.conf.js @@ -35,14 +35,14 @@ module.exports = function(config) { 'src/content/jcr_root/apps/core/wcm/components/image/v3/image/clientlibs/site/**/js/*.js', 'src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/authoring/js/editAction.js', 'src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/authoring/js/vcfRenderer.js', + 'src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/htmlidvalidator/js/*.js', + 'src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/*.js', 'src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/js/editDialog.js', { pattern: 'src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/site/js/vcf.js', included: false, served: true }, - 'src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/htmlidvalidator/js/*.js', - 'src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/*.js', 'src/content/jcr_root/apps/core/wcm/components/image/v2/image/clientlibs/editor/js/image.js', 'src/content/jcr_root/apps/core/wcm/components/image/v3/image/clientlibs/editor/js/image.js', 'test/**/*Test.js', diff --git a/content/src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/authoringMarkupUtils.js b/content/src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/authoringMarkupUtils.js index 18f2684f5f..c132b3fc12 100644 --- a/content/src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/authoringMarkupUtils.js +++ b/content/src/content/jcr_root/apps/core/wcm/components/commons/editor/clientlibs/authoringutils/js/authoringMarkupUtils.js @@ -39,6 +39,19 @@ "data-thumbnail-config-path" ]; + /** Element names removed entirely when normalizing parsed authoring datasource markup. */ + var AUTHORING_MARKUP_STRIPPED_ELEMENT_TAGS = { + SCRIPT: true, + IFRAME: true, + OBJECT: true, + EMBED: true, + STYLE: true, + LINK: true, + META: true, + BASE: true, + FORM: true + }; + /** * Parses an HTML document string into a Document instance. * @@ -81,17 +94,44 @@ return node; } + /** + * Drops ASCII C0 controls, DEL, and whitespace so scheme prefix checks cannot be bypassed with + * characters the URL layer may normalise away (e.g. TAB inside {@code javascript:}). + * + * @param {String} str - raw attribute value + * @returns {String} characters kept for scheme prefix checks + */ + function stripAsciiControlsAndWhitespaceForSchemeCheck(str) { + var out = ""; + var i; + var ch; + var c; + for (i = 0; i < str.length; i++) { + ch = str.charAt(i); + c = str.charCodeAt(i); + if (c <= 31 || c === 127) { + continue; + } + if (/\s/.test(ch)) { + continue; + } + out += ch; + } + return out; + } + /** * Whether a link-like attribute value uses a non-http(s) scheme prefix that authoring dialogs do not treat as repository paths. + * Leading C0 control characters, DEL, and whitespace are stripped before the check so values cannot hide schemes from prefix matching. * - * @param {*} value - attribute value - * @returns {Boolean} + * @param {*} value - attribute value (typically after DOM parsing, so entities are decoded) + * @returns {Boolean} true when the normalised value starts with javascript, data, or vbscript */ function linkValueHasExcludedRepositoryPrefix(value) { if (value === undefined || value === null) { return false; } - var t = String(value).trim().toLowerCase(); + var t = stripAsciiControlsAndWhitespaceForSchemeCheck(String(value)).toLowerCase(); return ( t.indexOf("javascript:") === 0 || t.indexOf("data:") === 0 || @@ -99,6 +139,89 @@ ); } + /** + * Normalizes parsed authoring markup under a root element: drops disallowed subtrees (including + * active content, document-influencing, and styling hooks) and clears event-handler and disallowed URL schemes on link-like attributes. + * + * @param {Element} rootElement - parsed subtree root (typically {@code document.body}) + */ + function sanitizeAuthoringMarkupSubtree(rootElement) { + if (!rootElement || rootElement.nodeType !== 1) { + return; + } + var all = rootElement.querySelectorAll("*"); + var list = []; + var i; + for (i = 0; i < all.length; i++) { + list.push(all[i]); + } + list.push(rootElement); + var removeEls = []; + for (i = 0; i < list.length; i++) { + var el = list[i]; + if (el.nodeType !== 1) { + continue; + } + var tag = el.tagName; + if (AUTHORING_MARKUP_STRIPPED_ELEMENT_TAGS[tag]) { + removeEls.push(el); + continue; + } + var attrs = el.attributes; + var names = []; + var j; + for (j = 0; attrs && j < attrs.length; j++) { + names.push(attrs[j].name); + } + for (j = 0; j < names.length; j++) { + var name = names[j]; + var val = el.getAttribute(name); + var nl = name.toLowerCase(); + if (/^on/i.test(name)) { + el.removeAttribute(name); + continue; + } + if ( + ( + nl === "href" || + nl === "src" || + nl === "action" || + nl === "formaction" || + nl === "xlink:href" + ) && + linkValueHasExcludedRepositoryPrefix(val) + ) { + el.removeAttribute(name); + } + } + } + for (i = 0; i < removeEls.length; i++) { + var node = removeEls[i]; + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } + } + + /** + * Parses datasource HTML and returns the inner markup of the first body child, after subtree + * normalization is applied to the parsed document body. + * + * @param {String} markup - HTML document string from a datasource response + * @returns {String} normalized inner markup (empty string when the parsed body has no element child) + */ + function sanitizeAuthoringEditorResponseMarkup(markup) { + var doc = parseMarkupDocument(String(markup == null ? "" : markup)); + if (doc.body) { + sanitizeAuthoringMarkupSubtree(doc.body); + } + var body = doc.body; + if (!body || !body.firstElementChild) { + return ""; + } + return body.firstElementChild.innerHTML; + } + function filterClassAttribute(raw, allowedTokens) { if (!raw || typeof raw !== "string") { return ""; @@ -236,7 +359,8 @@ innerHtmlFromFirstBodyChild: innerHtmlFromFirstBodyChild, adoptNodeForDocument: adoptNodeForDocument, linkValueHasExcludedRepositoryPrefix: linkValueHasExcludedRepositoryPrefix, - buildPageImageThumbnailShellForEditor: buildPageImageThumbnailShellForEditor + buildPageImageThumbnailShellForEditor: buildPageImageThumbnailShellForEditor, + sanitizeAuthoringEditorResponseMarkup: sanitizeAuthoringEditorResponseMarkup }; })(window); diff --git a/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/.content.xml b/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/.content.xml index 04103836f4..b2a5a45a8c 100644 --- a/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/.content.xml +++ b/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/.content.xml @@ -1,4 +1,5 @@ + categories="[core.wcm.components.contentfragment.v1.dialog]" + dependencies="[core.wcm.components.commons.editor.authoringutils]"/> diff --git a/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/js/editDialog.js b/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/js/editDialog.js index dfb4914b7b..2f1d9c67fb 100644 --- a/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/js/editDialog.js +++ b/content/src/content/jcr_root/apps/core/wcm/components/contentfragment/v1/contentfragment/clientlibs/editor/dialog/js/editDialog.js @@ -49,6 +49,71 @@ // ui helper var ui = $(window).adaptTo("foundation-ui"); + var CT_SITES_41323 = "CT_SITES-41323"; + + /** + * Granite feature toggle CT_SITES-41323 gates datasource HTML handling for the element names container. + * When Granite or {@code Toggles} is unavailable, helpers are treated as enabled. + * When the toggle is explicitly disabled, behaviour matches earlier dialog revisions. + * + * @returns {Boolean} + */ + function isContentFragmentV1DialogAuthoringMarkupHelpersEnabled() { + if (typeof Granite === "undefined" || !Granite.Toggles || typeof Granite.Toggles.isEnabled !== "function") { + return true; + } + return Granite.Toggles.isEnabled(CT_SITES_41323) !== false; + } + + /** + * @returns {Object|null} {@code CQ.CoreComponents.AuthoringEditorUtils.markup} when present + */ + function getAuthoringMarkupUtils() { + if (window.CQ && window.CQ.CoreComponents && window.CQ.CoreComponents.AuthoringEditorUtils) { + return window.CQ.CoreComponents.AuthoringEditorUtils.markup; + } + return null; + } + + function extractFirstParsedInnerHtml(html) { + if (typeof $ === "function" && typeof $.parseHTML === "function") { + var nodes = $.parseHTML(String(html == null ? "" : html), document, true); + var i; + for (i = 0; i < nodes.length; i++) { + if (nodes[i].nodeType === 1) { + return nodes[i].innerHTML; + } + } + return ""; + } + var fallbackMarkup = getAuthoringMarkupUtils(); + if (fallbackMarkup && typeof fallbackMarkup.innerHtmlFromFirstBodyChild === "function") { + return fallbackMarkup.innerHtmlFromFirstBodyChild(html); + } + var first = $(html)[0]; + if (!first || typeof first.innerHTML !== "string") { + return ""; + } + return first.innerHTML; + } + + /** + * Resolves inner HTML for the element names container from a datasource response string. + * + * @param {String} html - outer HTML for the element names container + * @returns {String} + */ + function resolveElementNamesContainerInnerHtml(html) { + if (!isContentFragmentV1DialogAuthoringMarkupHelpersEnabled()) { + return extractFirstParsedInnerHtml(html); + } + var markupUtils = getAuthoringMarkupUtils(); + if (markupUtils && typeof markupUtils.sanitizeAuthoringEditorResponseMarkup === "function") { + return markupUtils.sanitizeAuthoringEditorResponseMarkup(html); + } + return extractFirstParsedInnerHtml(html); + } + // dialog texts var confirmationDialogTitle = Granite.I18n.get("Warning"); var confirmationDialogMessage = Granite.I18n.get("Please confirm replacing the current content fragment and its configuration"); @@ -351,7 +416,7 @@ * @param {String} html - outerHTML value for elementNamesContainer */ ElementsController.prototype._updateElementsHTML = function(html) { - this.elementNamesContainer.innerHTML = $(html)[0].innerHTML; + this.elementNamesContainer.innerHTML = resolveElementNamesContainerInnerHtml(html); this._updateFields(); }; @@ -868,4 +933,14 @@ } }); + var cfV1DialogTestApiHost = typeof globalThis !== "undefined" ? globalThis : window; + /* Karma (mocks.js) sets __CONTENTFRAGMENT_V1_DIALOG_TEST_API on the global object; AEM runtime leaves it undefined. */ + if (cfV1DialogTestApiHost.__CONTENTFRAGMENT_V1_DIALOG_TEST_API) { + cfV1DialogTestApiHost.__CONTENTFRAGMENT_V1_DIALOG_TEST_API.resolveElementNamesContainerInnerHtml = + resolveElementNamesContainerInnerHtml; + cfV1DialogTestApiHost.__CONTENTFRAGMENT_V1_DIALOG_TEST_API.isContentFragmentV1DialogAuthoringMarkupHelpersEnabled = + isContentFragmentV1DialogAuthoringMarkupHelpersEnabled; + cfV1DialogTestApiHost.__CONTENTFRAGMENT_V1_DIALOG_TEST_API.getAuthoringMarkupUtils = getAuthoringMarkupUtils; + } + })(window, jQuery, jQuery(document), Granite, Coral); diff --git a/content/test/clientlibs/authoringutils/authoringMarkupUtilsTest.js b/content/test/clientlibs/authoringutils/authoringMarkupUtilsTest.js index d25a020d00..1f1781ef61 100644 --- a/content/test/clientlibs/authoringutils/authoringMarkupUtilsTest.js +++ b/content/test/clientlibs/authoringutils/authoringMarkupUtilsTest.js @@ -41,6 +41,22 @@ describe("AuthoringEditorUtils.markup (core.wcm.components.commons.editor.author expect(markupUtils.linkValueHasExcludedRepositoryPrefix("DATA:image/png;base64,xx")).toBe(true); expect(markupUtils.linkValueHasExcludedRepositoryPrefix(" javascript:x ")).toBe(true); }); + + it("returns true when C0 controls or whitespace break up or precede the scheme", function() { + expect(markupUtils.linkValueHasExcludedRepositoryPrefix(" javascript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("\n\tjavascript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("java\tscript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("java\nscript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("jav\rascript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("\u0000javascript:alert(1)")).toBe(true); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("\u0001javascript:alert(1)")).toBe(true); + }); + + it("returns false for https URLs and nullish values", function() { + expect(markupUtils.linkValueHasExcludedRepositoryPrefix("https://example.com/x")).toBe(false); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix(null)).toBe(false); + expect(markupUtils.linkValueHasExcludedRepositoryPrefix(undefined)).toBe(false); + }); }); describe("buildPageImageThumbnailShellForEditor", function() { @@ -154,4 +170,53 @@ describe("AuthoringEditorUtils.markup (core.wcm.components.commons.editor.author expect(img.getAttribute("src")).toBe("/content/dam/x.png"); }); }); + + describe("sanitizeAuthoringEditorResponseMarkup", function() { + it("returns inner markup of the first body child with event attributes removed", function() { + const html = + "
"; + const out = markupUtils.sanitizeAuthoringEditorResponseMarkup(html); + expect(out.indexOf("onerror")).toBe(-1); + expect(out.indexOf("onmouseover")).toBe(-1); + expect(out.indexOf("")).toBe(-1); + expect(out.indexOf(">ok

")).not.toBe(-1); + }); + + it("removes href with disallowed schemes", function() { + const html = "
t
"; + const out = markupUtils.sanitizeAuthoringEditorResponseMarkup(html); + expect(out.indexOf("javascript")).toBe(-1); + }); + + it("removes action with disallowed schemes", function() { + const html = "
t
"; + const out = markupUtils.sanitizeAuthoringEditorResponseMarkup(html); + expect(out.indexOf("javascript")).toBe(-1); + }); + + it("drops style, link, meta, base, and form elements", function() { + const html = + "
" + + "" + + "" + + "

ok

"; + const out = markupUtils.sanitizeAuthoringEditorResponseMarkup(html); + expect(out.indexOf("ok

")).not.toBe(-1); + }); + + it("returns empty string when body has no element child", function() { + expect(markupUtils.sanitizeAuthoringEditorResponseMarkup("")).toBe(""); + }); + }); }); diff --git a/content/test/clientlibs/contentfragment/editDialogTest.js b/content/test/clientlibs/contentfragment/editDialogTest.js index a1dae3c69f..9f3b8e28ed 100644 --- a/content/test/clientlibs/contentfragment/editDialogTest.js +++ b/content/test/clientlibs/contentfragment/editDialogTest.js @@ -13,6 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. ******************************************************************************/ +/** + * Helpers for {@code Granite.Toggles} during editDialog markup-helper test suites + * ({@code globalThis.__CONTENTFRAGMENT_V1_DIALOG_TEST_API}). + */ +function editDialogTest_graniteAllOn() { + globalThis.Granite.Toggles.isEnabled = function() { + return true; + }; +} + +function editDialogTest_graniteCt41323Off() { + globalThis.Granite.Toggles.isEnabled = function(key) { + return key !== "CT_SITES-41323"; + }; +} + describe("Test editDialog VCF template retention for", function() { let channel; @@ -167,3 +183,67 @@ describe("Test editDialog VCF template retention for", function() { }); }); + +/** + * Covers Content Fragment v1 editor {@code editDialog.js} (CT_SITES-41323) wired through + * {@code globalThis.__CONTENTFRAGMENT_V1_DIALOG_TEST_API}. Karma loads {@code authoringutils} before {@code editDialog.js}. + */ +describe("Content Fragment v1 editor editDialog.js (Karma-loaded)", function() { + let api; + let togglesIsEnabled; + + beforeAll(function() { + api = globalThis.__CONTENTFRAGMENT_V1_DIALOG_TEST_API; + togglesIsEnabled = globalThis.Granite.Toggles.isEnabled; + }); + + afterEach(function() { + globalThis.Granite.Toggles.isEnabled = togglesIsEnabled; + }); + + describe("__CONTENTFRAGMENT_V1_DIALOG_TEST_API", function() { + it("exposes resolve and toggle helpers", function() { + expect(api).toBeDefined(); + expect(typeof api.resolveElementNamesContainerInnerHtml).toBe("function"); + expect(typeof api.isContentFragmentV1DialogAuthoringMarkupHelpersEnabled).toBe("function"); + expect(typeof api.getAuthoringMarkupUtils).toBe("function"); + }); + }); + + describe("isContentFragmentV1DialogAuthoringMarkupHelpersEnabled", function() { + it("treats missing Granite.Toggles as enabled", function() { + const saved = globalThis.Granite.Toggles; + globalThis.Granite.Toggles = undefined; + expect(api.isContentFragmentV1DialogAuthoringMarkupHelpersEnabled()).toBe(true); + globalThis.Granite.Toggles = saved; + }); + + it("returns false when CT_SITES-41323 is explicitly disabled", function() { + editDialogTest_graniteCt41323Off(); + expect(api.isContentFragmentV1DialogAuthoringMarkupHelpersEnabled()).toBe(false); + }); + + it("returns true when CT_SITES-41323 is enabled", function() { + editDialogTest_graniteAllOn(); + expect(api.isContentFragmentV1DialogAuthoringMarkupHelpersEnabled()).toBe(true); + }); + }); + + describe("resolveElementNamesContainerInnerHtml", function() { + it("normalizes markup when helpers are enabled", function() { + editDialogTest_graniteAllOn(); + const doc = + "
"; + const inner = api.resolveElementNamesContainerInnerHtml(doc); + expect(inner.indexOf("onerror")).toBe(-1); + }); + + it("keeps legacy parsing when helpers are disabled", function() { + editDialogTest_graniteCt41323Off(); + const doc = + "
"; + const inner = api.resolveElementNamesContainerInnerHtml(doc); + expect(inner.indexOf("onerror")).not.toBe(-1); + }); + }); +}); diff --git a/content/test/mocks.js b/content/test/mocks.js index 9309133124..178d76f71d 100644 --- a/content/test/mocks.js +++ b/content/test/mocks.js @@ -427,3 +427,5 @@ if (!globalThis.CQ.CoreComponents.CheckboxTextfieldTuple) { /** Filled by Image v2 or v3 editor image.js when present (Karma loads those scripts after mocks). */ globalThis.__IMAGE_V2_EDITOR_TEST_API = {}; globalThis.__IMAGE_V3_EDITOR_TEST_API = {}; +/** Filled by Content Fragment v1 editDialog.js when present (Karma loads that script after mocks). */ +globalThis.__CONTENTFRAGMENT_V1_DIALOG_TEST_API = {};