',
+ "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("");
+ });
+});