Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions js/SaveInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,34 @@ class SaveInterface {
* @instance
*/
prepareHTML() {
const escapeHTML = value => {
if (value === null || value === undefined) return "";
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
};

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) {
Expand All @@ -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;
}

Expand Down
37 changes: 28 additions & 9 deletions js/__tests__/SaveInterface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,22 +184,41 @@ 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("<h1>Mock Project</h1>");
expect(file).toContain("<p>Mock Description</p>");
expect(file).toContain("<img src='mock-image.png'/>");
expect(file).toContain("<div>Mock Exported Data</div>");
});

it("should escape project name/description/data to prevent HTML injection", () => {
instance.activity = {
...activity,
PlanetInterface: {
...activity.PlanetInterface,
getCurrentProjectName: jest.fn(() => "</title><img src=x onerror=alert(1)><title>"),
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 }}</title><p>{{ project_description }}</p><img src="{{ project_image }}"><div>{{ data }}</div>';

const file = instance.prepareHTML();

expect(file).toContain("&lt;/title&gt;&lt;img src=x onerror=alert(1)&gt;&lt;title&gt;");
expect(file).toContain("&lt;b&gt;desc&lt;/b&gt;");
expect(file).toContain("x&quot; onerror=alert(1)&quot;");
expect(file).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(file).not.toContain("<img src=x onerror=alert(1)>");
expect(file).not.toContain("<script>alert(1)</script>");
});

it("should call prepareHTML and download the file", () => {
const mockPrepareHTML = jest.fn(() => "<html>Mock HTML</html>");

Expand Down
28 changes: 19 additions & 9 deletions js/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<div class="code" id="codeBlock">(.+?)</div>'
)[1]
);
} else {
obj = JSON.parse(
cleanData.match('<div class="code">(.+?)</div>')[1]
const match = cleanData.includes('id="codeBlock"')
? cleanData.match(
'<div class="code" id="codeBlock">(.+?)</div>'
)
: cleanData.match('<div class="code">(.+?)</div>');
if (!match) {
throw new Error(
"Could not find project data in HTML file"
);
}

obj = JSON.parse(decodeHtmlEntities(match[1]));
} else {
obj = JSON.parse(cleanData);
}
Expand Down
Loading