diff --git a/README.md b/README.md
index d5f19eb20..0bd0a70a0 100644
--- a/README.md
+++ b/README.md
@@ -246,6 +246,20 @@ 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 and `javascript:` URLs are removed, as are
+stylesheets outside an SVG and any stylesheet that loads CSS from elsewhere.
+`data:` URLs are still allowed on images. `
",
+ }}
+ />,
+ );
+
+ 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..4e64301ba
--- /dev/null
+++ b/src/utils/sanitiseInstructions.js
@@ -0,0 +1,67 @@
+import DOMPurify from "dompurify";
+
+// 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",
+];
+
+const isProjectViewer = (src) => {
+ try {
+ return EMBED_ORIGINS.includes(new URL(src).origin);
+ } catch {
+ return false;
+ }
+};
+
+const sanitiseConfig = {
+ // `use` draws the icons in 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);
+
+const isLocalRef = (value) => value == null || value.startsWith("#");
+
+purifier.addHook("uponSanitizeElement", (node, { tagName }) => {
+ if (tagName === "iframe" && !isProjectViewer(node.getAttribute("src"))) {
+ 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);
+ }
+ }
+
+ if (
+ tagName === "use" &&
+ !(
+ isLocalRef(node.getAttribute("href")) &&
+ isLocalRef(node.getAttribute("xlink:href"))
+ )
+ ) {
+ 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..32d39f7ad
--- /dev/null
+++ b/src/utils/sanitiseInstructions.test.js
@@ -0,0 +1,243 @@
+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":
+ '',
+ "third party iframe": '',
+ "iframe srcdoc":
+ '',
+ "allowed origin iframe with srcdoc":
+ '',
+ object: '',
+ embed: '