From 33f89517b49246d4bd72c80443828002553b78d2 Mon Sep 17 00:00:00 2001 From: Chaitu7032 Date: Thu, 19 Feb 2026 14:34:17 +0530 Subject: [PATCH 1/4] Fix stored XSS in Save as HTML exports --- js/SaveInterface.js | 41 +++++++++++++++++++++++++++--- js/__tests__/SaveInterface.test.js | 39 +++++++++++++++++++++------- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/js/SaveInterface.js b/js/SaveInterface.js index 8fd7b6357e..e819c23652 100644 --- a/js/SaveInterface.js +++ b/js/SaveInterface.js @@ -225,6 +225,36 @@ class SaveInterface { * @instance */ prepareHTML() { + const escapeHTML = value => { + if (value === null || value === undefined) return ""; + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + }; + + const sanitizeImageURL = value => { + if (value === null || value === undefined) return ""; + const raw = String(value).trim(); + if (raw === "") return ""; + + const lower = raw.toLowerCase(); + if (lower.startsWith("data:")) { + // Only allow data:image/* to avoid data:text/html, etc. + return /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i.test(raw) + ? raw + : ""; + } + + if (lower.startsWith("http://") || lower.startsWith("https://")) return raw; + + // Allow relative URLs but reject other schemes. + if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return ""; + return raw; + }; + let file = this.htmlSaveTemplate; let description = _("No description provided"); if (this.activity.PlanetInterface !== undefined) { @@ -245,10 +275,13 @@ class SaveInterface { } file = file - .replace(new RegExp("{{ project_description }}", "g"), description) - .replace(new RegExp("{{ project_name }}", "g"), name) - .replace(new RegExp("{{ data }}", "g"), data) - .replace(new RegExp("{{ project_image }}", "g"), image); + .replace(new RegExp("{{ project_description }}", "g"), () => escapeHTML(description)) + .replace(new RegExp("{{ project_name }}", "g"), () => escapeHTML(name)) + // Always render project data as text, never as HTML. + .replace(new RegExp("{{ data }}", "g"), () => escapeHTML(data)) + .replace(new RegExp("{{ project_image }}", "g"), () => + escapeHTML(sanitizeImageURL(image)) + ); return file; } diff --git a/js/__tests__/SaveInterface.test.js b/js/__tests__/SaveInterface.test.js index 2c2b4eb48b..121d9c1994 100644 --- a/js/__tests__/SaveInterface.test.js +++ b/js/__tests__/SaveInterface.test.js @@ -184,15 +184,9 @@ describe("save HTML methods", () => { }); it("should replace placeholders with actual project data", () => { - let file = activity.htmlSaveTemplate; - file = file - .replace( - /{{ project_description }}/g, - activity.PlanetInterface.getCurrentProjectDescription() - ) - .replace(/{{ project_name }}/g, activity.PlanetInterface.getCurrentProjectName()) - .replace(/{{ data }}/g, activity.prepareExport()) - .replace(/{{ project_image }}/g, activity.PlanetInterface.getCurrentProjectImage()); + instance.activity = activity; + instance.htmlSaveTemplate = activity.htmlSaveTemplate; + const file = instance.prepareHTML(); expect(file).toContain("

Mock Project

"); expect(file).toContain("

Mock Description

"); @@ -200,6 +194,33 @@ describe("save HTML methods", () => { expect(file).toContain("
Mock Exported Data
"); }); + it("should escape project name/description/data to prevent HTML injection", () => { + instance.activity = { + ...activity, + PlanetInterface: { + ...activity.PlanetInterface, + getCurrentProjectName: jest.fn( + () => "" + ), + getCurrentProjectDescription: jest.fn(() => "<b>desc</b>"), + getCurrentProjectImage: jest.fn(() => "x\" onerror=alert(1)\"") + }, + prepareExport: jest.fn(() => "<script>alert(1)</script>") + }; + + instance.htmlSaveTemplate = + "<title>{{ project_name }}

{{ project_description }}

{{ data }}
"; + + const file = instance.prepareHTML(); + + expect(file).toContain("</title><img src=x onerror=alert(1)><title>"); + expect(file).toContain("<b>desc</b>"); + expect(file).toContain("x" onerror=alert(1)""); + expect(file).toContain("<script>alert(1)</script>"); + expect(file).not.toContain(""); + expect(file).not.toContain(""); + }); + it("should call prepareHTML and download the file", () => { const mockPrepareHTML = jest.fn(() => "Mock HTML"); From 4ada82fcddd237a63a4a05852e3787e422f899b4 Mon Sep 17 00:00:00 2001 From: Chaitu7032 Date: Thu, 19 Feb 2026 14:52:08 +0530 Subject: [PATCH 2/4] fixed ESLINT erros --- js/SaveInterface.js | 4 +--- js/__tests__/SaveInterface.test.js | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/js/SaveInterface.js b/js/SaveInterface.js index e819c23652..424e558458 100644 --- a/js/SaveInterface.js +++ b/js/SaveInterface.js @@ -243,9 +243,7 @@ class SaveInterface { const lower = raw.toLowerCase(); if (lower.startsWith("data:")) { // Only allow data:image/* to avoid data:text/html, etc. - return /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i.test(raw) - ? raw - : ""; + return /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i.test(raw) ? raw : ""; } if (lower.startsWith("http://") || lower.startsWith("https://")) return raw; diff --git a/js/__tests__/SaveInterface.test.js b/js/__tests__/SaveInterface.test.js index 121d9c1994..04206417c4 100644 --- a/js/__tests__/SaveInterface.test.js +++ b/js/__tests__/SaveInterface.test.js @@ -199,17 +199,15 @@ describe("save HTML methods", () => { ...activity, PlanetInterface: { ...activity.PlanetInterface, - getCurrentProjectName: jest.fn( - () => "" - ), + getCurrentProjectName: jest.fn(() => ""), getCurrentProjectDescription: jest.fn(() => "<b>desc</b>"), - getCurrentProjectImage: jest.fn(() => "x\" onerror=alert(1)\"") + getCurrentProjectImage: jest.fn(() => 'x" onerror=alert(1)"') }, prepareExport: jest.fn(() => "<script>alert(1)</script>") }; instance.htmlSaveTemplate = - "<title>{{ project_name }}

{{ project_description }}

{{ data }}
"; + '{{ project_name }}

{{ project_description }}

{{ data }}
'; const file = instance.prepareHTML(); From 178ef56b208248940e0099d0c2d94980c65b8a2d Mon Sep 17 00:00:00 2001 From: Chaitu7032 Date: Fri, 20 Feb 2026 16:30:04 +0530 Subject: [PATCH 3/4] fixed import crash --- js/activity.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/js/activity.js b/js/activity.js index 36ec729e76..2681e23d78 100644 --- a/js/activity.js +++ b/js/activity.js @@ -7522,20 +7522,28 @@ class Activity { ); } else { const cleanData = rawData.replace("\n", " "); + + const decodeHtmlEntities = value => { + if (typeof value !== "string") return value; + if (value.indexOf("&") === -1) return value; + const textarea = document.createElement("textarea"); + textarea.innerHTML = value; + return textarea.value; + }; + let obj; try { if (cleanData.includes("html")) { - if (cleanData.includes('id="codeBlock"')) { - obj = JSON.parse( - cleanData.match( - '
(.+?)
' - )[1] - ); - } else { - obj = JSON.parse( - cleanData.match('
(.+?)
')[1] - ); + const match = cleanData.includes('id="codeBlock"') + ? cleanData.match( + '
(.+?)
' + ) + : cleanData.match('
(.+?)
'); + if (!match) { + throw new Error("Could not find project data in HTML file"); } + + obj = JSON.parse(decodeHtmlEntities(match[1])); } else { obj = JSON.parse(cleanData); } From 14727170f0af4f0795608de9177fcd95ea6a8326 Mon Sep 17 00:00:00 2001 From: Chaitu7032 Date: Fri, 20 Feb 2026 16:32:38 +0530 Subject: [PATCH 4/4] fixed linting in activity.js --- js/activity.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/activity.js b/js/activity.js index 2681e23d78..dd994a5a3c 100644 --- a/js/activity.js +++ b/js/activity.js @@ -7540,7 +7540,9 @@ class Activity { ) : cleanData.match('
(.+?)
'); if (!match) { - throw new Error("Could not find project data in HTML file"); + throw new Error( + "Could not find project data in HTML file" + ); } obj = JSON.parse(decodeHtmlEntities(match[1]));