Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

`<iframe>` is kept only when its `src` is `https://editor.raspberrypi.org` or
`https://staging-editor.raspberrypi.org`, so that the embedded project viewers
used by project site content keep working. Any other frame is removed.

#### Instructions Styling

Classes from the stringified HTML passed to the web component in the `instructions` attribute are being used to style the project steps in the instructions panel.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, { useEffect, useRef } from "react";
import { processEditorProject } from "@raspberrypifoundation/rpf-markdown-core";
import Prism from "../../../../../utils/prism";
import sanitiseInstructions from "../../../../../utils/sanitiseInstructions";
import { scratchblocksInit } from "../../../../../utils/scratchblocks";

const getStepHtml = (step) => {
if (step.content !== undefined) {
return step.content;
}
return processEditorProject(step.markdown_content ?? "");
const html =
step.content !== undefined
? step.content
: processEditorProject(step.markdown_content ?? "");

return sanitiseInstructions(html);
};

const applySyntaxHighlighting = (container) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,36 @@ describe("When the step has markdown_content", () => {
});
});

describe("Sanitising step HTML", () => {
test("Strips scripts from content supplied as HTML", () => {
const { container } = render(
<InstructionsStep
step={{
content: "<p>Step</p><script>window.hacked = true</script>",
}}
/>,
);

expect(screen.getByText("Step")).toBeInTheDocument();
expect(container.querySelector("script")).toBeNull();
});

test("Strips scripts from content supplied as markdown", () => {
const { container } = render(
<InstructionsStep
step={{
markdown_content: "# Title\n\n<script>window.hacked = true</script>",
}}
/>,
);

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(<InstructionsStep step={{ markdown_content }} />).container;
Expand Down
52 changes: 52 additions & 0 deletions src/utils/sanitiseInstructions.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
Comment thread
jamiebenstead marked this conversation as resolved.

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;
210 changes: 210 additions & 0 deletions src/utils/sanitiseInstructions.test.js
Original file line number Diff line number Diff line change
@@ -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>window.hacked = true</script>",
"script src": '<script src="https://evil.example/x.js"></script>',
"img onerror": '<img src="x" onerror="window.hacked = true">',
"svg onload": '<svg onload="window.hacked = true"></svg>',
"body onload": '<body onload="window.hacked = true">text</body>',
"details ontoggle":
'<details open ontoggle="window.hacked = true">x</details>',
"unknown element with handler":
'<xss id="x" tabindex="1" onfocus="window.hacked = true"></xss>',
"javascript href": '<a href="javascript:window.hacked = true">click</a>',
"javascript href with entities":
'<a href="java&#115;cript:window.hacked = true">click</a>',
"data url href":
'<a href="data:text/html;base64,PHNjcmlwdD53aW5kb3cuaGFja2VkPXRydWU8L3NjcmlwdD4=">click</a>',
"form action":
'<form action="javascript:window.hacked = true"><button>go</button></form>',
"formaction button":
'<button formaction="javascript:window.hacked = true">go</button>',
"third party iframe": '<iframe src="https://evil.example/x"></iframe>',
"iframe srcdoc":
'<iframe srcdoc="<script>window.hacked = true</script>"></iframe>',
"allowed origin iframe with srcdoc":
'<iframe src="https://editor.raspberrypi.org/en/embed/viewer/x" srcdoc="<script>window.hacked = true</script>"></iframe>',
object: '<object data="javascript:window.hacked = true"></object>',
embed: '<embed src="https://evil.example/x.swf">',
"meta refresh":
'<meta http-equiv="refresh" content="0;url=https://evil.example">',
base: '<base href="https://evil.example/">',
"link stylesheet":
'<link rel="stylesheet" href="https://evil.example/x.css">',
"style element": "<style>.project-instructions { display: none }</style>",
"style import": "<style>@import url(https://evil.example/x.css)</style>",
"external svg use":
'<svg><use href="https://evil.example/x.svg#y" /></svg>',
template: "<template><script>window.hacked = true</script></template>",
noscript:
'<noscript><p title="</noscript><img src=x onerror=window.hacked=true>">',
"mutation xss":
'<math><mtext><table><mglyph><style><!--</style><img title="--><textarea onfocus=window.hacked=true autofocus>"></mglyph></table></mtext></math>',
};

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<script>window.hacked = true</script>\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) =>
`<iframe src="${src}" width="600" height="600" frameborder="0" marginwidth="0" marginheight="0" allowfullscreen></iframe>`;

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(
'<h2 class="c-project-heading--task" id="step-1">Step 1</h2>' +
'<div class="c-project-callout c-project-callout--tip" style="font-size: 1.1em">' +
'<h3 id="tip">Tip</h3></div>' +
'<div class="c-project-task">' +
'<input class="c-project-task__checkbox" type="checkbox" aria-label="Mark this task as complete" />' +
"</div>",
);

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(
'<pre dir="ltr" class="line-numbers" data-start="10" data-line-offset="10" data-line="11">' +
'<code class="language-python" dir="ltr">print(&#39;Hello&#39;)</code></pre>',
);

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(
'<p><img src="https://projects-static.raspberrypi.org/projects/x/images/y.png" alt="A screenshot" width="300" /></p>' +
'<a href="https://projects.raspberrypi.org/en/projects/x" target="_blank">Link</a>',
);

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(
'<pre><code class="language-html">&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>',
);

expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("code").textContent).toEqual(
"<script>alert(1)</script>",
);
});
});

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(
'<pre><code class="language-blocks">when green flag clicked</code></pre>',
);

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("");
});
});
Loading