From 306e8c421bf7271ad787414084eedf6e4f2ca466 Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Fri, 28 Aug 2026 15:42:41 +0100
Subject: [PATCH 1/6] Sanitise project instructions before rendering them
Instruction step HTML was assigned to innerHTML unsanitised, so a step could
run script in the host page's origin, where the login session is stored. Every
step now goes through DOMPurify, covering both the `instructions` attribute and steps loaded with a project.
Two tags DOMPurify drops by default are kept because real content needs them:
`use`, which draws the icons in a scratchblocks SVG, and `iframe`, which is then restricted to the editor's own origins so the embedded project viewers in project instructions keep working.
---
README.md | 12 +
.../InstructionsStep/InstructionsStep.jsx | 11 +-
.../InstructionsStep.test.jsx | 30 +++
src/utils/sanitiseInstructions.js | 52 +++++
src/utils/sanitiseInstructions.test.js | 210 ++++++++++++++++++
5 files changed, 311 insertions(+), 4 deletions(-)
create mode 100644 src/utils/sanitiseInstructions.js
create mode 100644 src/utils/sanitiseInstructions.test.js
diff --git a/README.md b/README.md
index d5f19eb20..3752b6526 100644
--- a/README.md
+++ b/README.md
@@ -246,6 +246,18 @@ Styles from the parent application can be passed to the web component in a few d
}
```
+#### Instructions Sanitisation
+
+Instruction steps are rendered into the page with `innerHTML`, so every step is
+sanitised with DOMPurify first (see `src/utils/sanitiseInstructions.js`). This
+applies to steps passed in the `instructions` attribute and to steps loaded with
+a project. Scripts, event handler attributes, `javascript:` and `data:` URLs and
+stylesheets outside a scratchblocks SVG are removed.
+
+`
",
+ }}
+ />,
+ );
+
+ expect(screen.getByText("Step")).toBeInTheDocument();
+ expect(container.querySelector("script")).toBeNull();
+ });
+
+ test("Strips scripts from content supplied as markdown", () => {
+ const { container } = render(
+ window.hacked = true",
+ }}
+ />,
+ );
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Title" }),
+ ).toBeInTheDocument();
+ expect(container.querySelector("script")).toBeNull();
+ });
+});
+
describe("When markdown attaches a class to inline code", () => {
const renderMarkdown = (markdown_content) =>
render( ).container;
diff --git a/src/utils/sanitiseInstructions.js b/src/utils/sanitiseInstructions.js
new file mode 100644
index 000000000..5b8f90441
--- /dev/null
+++ b/src/utils/sanitiseInstructions.js
@@ -0,0 +1,52 @@
+import DOMPurify from "dompurify";
+
+// Some project steps embed the editor's own project viewer to show a worked
+// example. Frames from anywhere else are removed
+const EMBED_ORIGINS = [
+ "https://editor.raspberrypi.org",
+ "https://staging-editor.raspberrypi.org",
+];
+
+const isProjectViewer = (src) => {
+ try {
+ return EMBED_ORIGINS.includes(new URL(src, window.location.href).origin);
+ } catch {
+ return false;
+ }
+};
+
+const sanitiseConfig = {
+ // `use` draws the icons inside a scratchblocks SVG, such as the green flag
+ ADD_TAGS: ["iframe", "use"],
+ ADD_ATTR: [
+ "allowfullscreen",
+ "frameborder",
+ "marginheight",
+ "marginwidth",
+ "target",
+ ],
+};
+
+const purifier = DOMPurify(window);
+
+const remove = (node) => node.parentNode?.removeChild(node);
+
+purifier.addHook("uponSanitizeElement", (node, { tagName }) => {
+ if (tagName === "iframe" && !isProjectViewer(node.getAttribute("src"))) {
+ return remove(node);
+ }
+
+ // A stylesheet anywhere else would restyle the rest of the editor
+ if (tagName === "style" && !node.closest("svg")) {
+ return remove(node);
+ }
+
+ // Same document references only, so a `use` cannot pull in outside markup
+ if (tagName === "use" && !node.getAttribute("href")?.startsWith("#")) {
+ return remove(node);
+ }
+});
+
+const sanitiseInstructions = (html) => purifier.sanitize(html, sanitiseConfig);
+
+export default sanitiseInstructions;
diff --git a/src/utils/sanitiseInstructions.test.js b/src/utils/sanitiseInstructions.test.js
new file mode 100644
index 000000000..e0c26b97f
--- /dev/null
+++ b/src/utils/sanitiseInstructions.test.js
@@ -0,0 +1,210 @@
+import { processEditorProject } from "@raspberrypifoundation/rpf-markdown-core";
+import sanitiseInstructions from "./sanitiseInstructions";
+
+const parse = (html) => {
+ const container = document.createElement("div");
+ container.innerHTML = sanitiseInstructions(html);
+ return container;
+};
+
+const eventHandlerAttributes = (container) =>
+ Array.from(container.querySelectorAll("*")).flatMap((element) =>
+ Array.from(element.attributes)
+ .map((attribute) => attribute.name)
+ .filter((name) => name.startsWith("on")),
+ );
+
+describe("Scriptable payloads", () => {
+ const payloads = {
+ "script element": "",
+ "script src": '',
+ "img onerror": ' ',
+ "svg onload": ' ',
+ "body onload": 'text',
+ "details ontoggle":
+ 'x ',
+ "unknown element with handler":
+ ' ',
+ "javascript href": 'click ',
+ "javascript href with entities":
+ 'click ',
+ "data url href":
+ 'click ',
+ "form action":
+ '',
+ "formaction button":
+ 'go ',
+ "third party iframe": '',
+ "iframe srcdoc":
+ '',
+ "allowed origin iframe with srcdoc":
+ '',
+ object: ' ',
+ embed: '',
+ "meta refresh":
+ ' ',
+ base: ' ',
+ "link stylesheet":
+ ' ',
+ "style element": "",
+ "style import": "",
+ "external svg use":
+ ' ',
+ template: " ",
+ noscript:
+ '',
+ "mutation xss":
+ ' ',
+ };
+
+ test.each(Object.entries(payloads))("%s renders inert", (_name, payload) => {
+ const container = parse(payload);
+ const html = container.innerHTML;
+
+ expect(container.querySelector("script")).toBeNull();
+ expect(container.querySelector("style")).toBeNull();
+ expect(container.querySelector("iframe[srcdoc]")).toBeNull();
+ expect(
+ container.querySelector("object, embed, link, base, meta"),
+ ).toBeNull();
+ expect(eventHandlerAttributes(container)).toEqual([]);
+ expect(html).not.toMatch(/javascript:/i);
+ expect(html).not.toMatch(/data:text\/html/i);
+ expect(html).not.toMatch(/evil\.example/i);
+ });
+
+ test("Strips scriptable payloads written as markdown", () => {
+ const container = parse(
+ processEditorProject(
+ "[click](javascript:alert)\n\n\n",
+ ),
+ );
+
+ expect(container.querySelector("script")).toBeNull();
+ expect(container.querySelector("a")).not.toBeNull();
+ expect(container.querySelector('a[href^="javascript:"]')).toBeNull();
+ });
+});
+
+describe("Embedded project viewers", () => {
+ const embed = (src) =>
+ ``;
+
+ test.each([
+ "https://editor.raspberrypi.org/en/embed/viewer/editor-mapping-data-step-2",
+ "https://staging-editor.raspberrypi.org/embed/viewer/fruit-face-example?show_visual_tab=true",
+ ])("Keeps the embed at %s", (src) => {
+ const iframe = parse(embed(src)).querySelector("iframe");
+
+ expect(iframe).not.toBeNull();
+ expect(iframe.getAttribute("src")).toEqual(src);
+ expect(iframe.getAttribute("width")).toEqual("600");
+ expect(iframe.getAttribute("allowfullscreen")).not.toBeNull();
+ });
+
+ test.each([
+ "https://evil.example/x",
+ "https://editor.raspberrypi.org.evil.example/x",
+ "//evil.example/x",
+ "/en/embed/viewer/x",
+ ])("Removes the embed at %s", (src) => {
+ expect(parse(embed(src)).querySelector("iframe")).toBeNull();
+ });
+});
+
+describe("Project site content", () => {
+ test("Keeps callouts, task checkboxes and headings", () => {
+ const container = parse(
+ '
Step 1 ' +
+ '' +
+ '
Tip ' +
+ '' +
+ ' ' +
+ "
",
+ );
+
+ expect(container.querySelector("h2.c-project-heading--task").id).toEqual(
+ "step-1",
+ );
+ expect(
+ container.querySelector(".c-project-callout--tip").getAttribute("style"),
+ ).toEqual("font-size: 1.1em");
+ expect(container.querySelector("h3#tip")).not.toBeNull();
+ expect(
+ container
+ .querySelector('input[type="checkbox"]')
+ .getAttribute("aria-label"),
+ ).toEqual("Mark this task as complete");
+ });
+
+ test("Keeps the attributes the syntax highlighter relies on", () => {
+ const container = parse(
+ '' +
+ 'print('Hello') ',
+ );
+
+ const pre = container.querySelector("pre");
+ expect(pre.getAttribute("data-line")).toEqual("11");
+ expect(pre.getAttribute("data-start")).toEqual("10");
+ expect(pre.getAttribute("data-line-offset")).toEqual("10");
+ expect(pre.getAttribute("dir")).toEqual("ltr");
+ expect(container.querySelector("code.language-python").textContent).toEqual(
+ "print('Hello')",
+ );
+ });
+
+ test("Keeps images and links", () => {
+ const container = parse(
+ '
' +
+ 'Link ',
+ );
+
+ expect(container.querySelector("img").alt).toEqual("A screenshot");
+ expect(container.querySelector("a").getAttribute("target")).toEqual(
+ "_blank",
+ );
+ });
+
+ test("Keeps code samples that contain HTML", () => {
+ const container = parse(
+ '<script>alert(1)</script> ',
+ );
+
+ expect(container.querySelector("script")).toBeNull();
+ expect(container.querySelector("code").textContent).toEqual(
+ "",
+ );
+ });
+});
+
+describe("Scratch blocks", () => {
+ const scratchblocksHtml = processEditorProject(
+ "```blocks\nwhen green flag clicked\nsay [Hello] for (2) seconds\n```\n",
+ );
+
+ test("Keeps the rendered SVG, its stylesheet and its icons", () => {
+ const container = parse(scratchblocksHtml);
+
+ const svg = container.querySelector("svg");
+ expect(svg).not.toBeNull();
+ expect(svg.querySelector("style")).not.toBeNull();
+ expect(svg.querySelectorAll("use").length).toBeGreaterThan(0);
+ expect(svg.querySelector("use").getAttribute("href")).toMatch(/^#/);
+ });
+
+ test("Keeps the block markup the editor renders client side", () => {
+ const container = parse(
+ 'when green flag clicked ',
+ );
+
+ expect(container.querySelector("code.language-blocks").textContent).toEqual(
+ "when green flag clicked",
+ );
+ });
+});
+
+describe("When there is nothing to sanitise", () => {
+ test.each([undefined, null, ""])("Returns an empty string for %s", (html) => {
+ expect(sanitiseInstructions(html)).toEqual("");
+ });
+});
From f1c43ff7eeb9553f39a82d287bd1e403c55fa568 Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Fri, 28 Aug 2026 16:33:38 +0100
Subject: [PATCH 2/6] Reject relative iframe src values in instructions
---
src/utils/sanitiseInstructions.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils/sanitiseInstructions.js b/src/utils/sanitiseInstructions.js
index 5b8f90441..cf42f31f4 100644
--- a/src/utils/sanitiseInstructions.js
+++ b/src/utils/sanitiseInstructions.js
@@ -9,7 +9,7 @@ const EMBED_ORIGINS = [
const isProjectViewer = (src) => {
try {
- return EMBED_ORIGINS.includes(new URL(src, window.location.href).origin);
+ return EMBED_ORIGINS.includes(new URL(src).origin);
} catch {
return false;
}
From 08da76881df4bac8983faf4b7fb453998e5135a5 Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Tue, 1 Sep 2026 15:08:01 +0100
Subject: [PATCH 3/6] Drop instruction stylesheets that load CSS from outside
---
src/utils/sanitiseInstructions.js | 22 +++++++++++++++-------
src/utils/sanitiseInstructions.test.js | 16 ++++++++++++++++
2 files changed, 31 insertions(+), 7 deletions(-)
diff --git a/src/utils/sanitiseInstructions.js b/src/utils/sanitiseInstructions.js
index cf42f31f4..9f254a3d3 100644
--- a/src/utils/sanitiseInstructions.js
+++ b/src/utils/sanitiseInstructions.js
@@ -1,7 +1,7 @@
import DOMPurify from "dompurify";
-// Some project steps embed the editor's own project viewer to show a worked
-// example. Frames from anywhere else are removed
+// Some project steps embed the editor's own project viewer. Nothing else may be
+// framed
const EMBED_ORIGINS = [
"https://editor.raspberrypi.org",
"https://staging-editor.raspberrypi.org",
@@ -16,7 +16,7 @@ const isProjectViewer = (src) => {
};
const sanitiseConfig = {
- // `use` draws the icons inside a scratchblocks SVG, such as the green flag
+ // `use` draws the icons in a scratchblocks SVG, such as the green flag
ADD_TAGS: ["iframe", "use"],
ADD_ATTR: [
"allowfullscreen",
@@ -36,12 +36,20 @@ purifier.addHook("uponSanitizeElement", (node, { tagName }) => {
return remove(node);
}
- // A stylesheet anywhere else would restyle the rest of the editor
- if (tagName === "style" && !node.closest("svg")) {
- return remove(node);
+ // Only scratchblocks needs a stylesheet, and it puts one inside each SVG it
+ // renders. `url(#...)` is its own SVG filters; anything loaded from outside
+ // would report who is reading the instructions
+ if (tagName === "style") {
+ const css = node.textContent ?? "";
+ const loadsOutsideCss =
+ /@import/i.test(css) || /url\(\s*['"]?(?!#)/i.test(css);
+
+ if (!node.closest("svg") || loadsOutsideCss) {
+ return remove(node);
+ }
}
- // Same document references only, so a `use` cannot pull in outside markup
+ // A `use` may only reference this page, not outside markup
if (tagName === "use" && !node.getAttribute("href")?.startsWith("#")) {
return remove(node);
}
diff --git a/src/utils/sanitiseInstructions.test.js b/src/utils/sanitiseInstructions.test.js
index e0c26b97f..9dc5c1cbf 100644
--- a/src/utils/sanitiseInstructions.test.js
+++ b/src/utils/sanitiseInstructions.test.js
@@ -48,6 +48,10 @@ describe("Scriptable payloads", () => {
' ',
"style element": "",
"style import": "",
+ "svg style with an import":
+ " ",
+ "svg style loading an external image":
+ " ",
"external svg use":
' ',
template: " ",
@@ -192,6 +196,18 @@ describe("Scratch blocks", () => {
expect(svg.querySelector("use").getAttribute("href")).toMatch(/^#/);
});
+ test("Keeps a stylesheet inside an SVG, unless it loads CSS from outside", () => {
+ const kept = parse(
+ " ",
+ );
+ const removed = parse(
+ " ",
+ );
+
+ expect(kept.querySelector("svg style")).not.toBeNull();
+ expect(removed.querySelector("style")).toBeNull();
+ });
+
test("Keeps the block markup the editor renders client side", () => {
const container = parse(
'when green flag clicked ',
From 318e347c26c5d470fcc454f2fa6d26b015b5bed3 Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Tue, 1 Sep 2026 15:19:20 +0100
Subject: [PATCH 4/6] Remove elements with an external xlink:href
---
src/utils/sanitiseInstructions.js | 11 +++++++++--
src/utils/sanitiseInstructions.test.js | 2 ++
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/utils/sanitiseInstructions.js b/src/utils/sanitiseInstructions.js
index 9f254a3d3..4e64301ba 100644
--- a/src/utils/sanitiseInstructions.js
+++ b/src/utils/sanitiseInstructions.js
@@ -31,6 +31,8 @@ const purifier = DOMPurify(window);
const remove = (node) => node.parentNode?.removeChild(node);
+const isLocalRef = (value) => value == null || value.startsWith("#");
+
purifier.addHook("uponSanitizeElement", (node, { tagName }) => {
if (tagName === "iframe" && !isProjectViewer(node.getAttribute("src"))) {
return remove(node);
@@ -49,8 +51,13 @@ purifier.addHook("uponSanitizeElement", (node, { tagName }) => {
}
}
- // A `use` may only reference this page, not outside markup
- if (tagName === "use" && !node.getAttribute("href")?.startsWith("#")) {
+ if (
+ tagName === "use" &&
+ !(
+ isLocalRef(node.getAttribute("href")) &&
+ isLocalRef(node.getAttribute("xlink:href"))
+ )
+ ) {
return remove(node);
}
});
diff --git a/src/utils/sanitiseInstructions.test.js b/src/utils/sanitiseInstructions.test.js
index 9dc5c1cbf..dac47a8ae 100644
--- a/src/utils/sanitiseInstructions.test.js
+++ b/src/utils/sanitiseInstructions.test.js
@@ -54,6 +54,8 @@ describe("Scriptable payloads", () => {
" ",
"external svg use":
' ',
+ "svg use with an external xlink:href":
+ ' ',
template: " ",
noscript:
'',
From d6cbd4c0044f877025c439b87ab2884af063102c Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Tue, 1 Sep 2026 15:25:47 +0100
Subject: [PATCH 5/6] Test relative embeds while the page is on an allowed
origin
---
src/utils/sanitiseInstructions.test.js | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/utils/sanitiseInstructions.test.js b/src/utils/sanitiseInstructions.test.js
index dac47a8ae..32d39f7ad 100644
--- a/src/utils/sanitiseInstructions.test.js
+++ b/src/utils/sanitiseInstructions.test.js
@@ -116,6 +116,21 @@ describe("Embedded project viewers", () => {
])("Removes the embed at %s", (src) => {
expect(parse(embed(src)).querySelector("iframe")).toBeNull();
});
+
+ test("Removes relative embeds when the page is on an allowed origin", () => {
+ window.jsdom.reconfigure({
+ url: "https://editor.raspberrypi.org/en/projects/foo",
+ });
+
+ expect(
+ parse(embed("/en/embed/viewer/x")).querySelector("iframe"),
+ ).toBeNull();
+ expect(
+ parse(embed("//editor.raspberrypi.org/en/embed/viewer/x")).querySelector(
+ "iframe",
+ ),
+ ).toBeNull();
+ });
});
describe("Project site content", () => {
From dfbb0de39efc4576316f8c990783b43752d3c972 Mon Sep 17 00:00:00 2001
From: Jamie Benstead
Date: Tue, 1 Sep 2026 15:45:29 +0100
Subject: [PATCH 6/6] Update readme
---
README.md | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 3752b6526..0bd0a70a0 100644
--- a/README.md
+++ b/README.md
@@ -251,12 +251,14 @@ Styles from the parent application can be passed to the web component in a few d
Instruction steps are rendered into the page with `innerHTML`, so every step is
sanitised with DOMPurify first (see `src/utils/sanitiseInstructions.js`). This
applies to steps passed in the `instructions` attribute and to steps loaded with
-a project. Scripts, event handler attributes, `javascript:` and `data:` URLs and
-stylesheets outside a scratchblocks SVG are removed.
-
-`