diff --git a/js/SaveInterface.js b/js/SaveInterface.js index 8fd7b6357e..424e558458 100644 --- a/js/SaveInterface.js +++ b/js/SaveInterface.js @@ -225,6 +225,34 @@ 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 +273,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..04206417c4 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,31 @@ 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"); diff --git a/js/activity.js b/js/activity.js index 36ec729e76..dd994a5a3c 100644 --- a/js/activity.js +++ b/js/activity.js @@ -7522,20 +7522,30 @@ 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); }