Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ REACT_APP_SENTRY_DSN=''
REACT_APP_SENTRY_ENV='local'
PUBLIC_URL='http://localhost:3011'
ASSETS_URL='http://localhost:3011'
HTML_RENDERER_URL='http://localhost:3003'
REACT_APP_GOOGLE_TAG_MANAGER_ID=''
REACT_APP_API_ENDPOINT='http://localhost:3009'
REACT_APP_PLAUSIBLE_DATA_DOMAIN=''
REACT_APP_PLAUSIBLE_SOURCE=''
REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012'
REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012'
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ node_modules

# production
/build
/build-html-renderer
/public/storybook

# misc
Expand All @@ -33,3 +34,4 @@ yarn-error.log*
.yarn/install-state.gz

.vscode/settings.json
.idea/*
99 changes: 51 additions & 48 deletions cypress/e2e/spec-html.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const getIframeDocument = () => {
.shadow()
.find("iframe[class=htmlrunner-iframe]")
.its("0.contentDocument")
.find("iframe[title=preview-sandbox]")
.its("0.contentDocument")
.should("exist");
};

Expand All @@ -30,6 +32,14 @@ const makeNewFile = (filename = "new.html") => {
.click();
};

const getEditorInput = () => {
return cy.get("editor-wc").shadow().find("div[class=cm-content]");
};

const getRunButton = () => {
return cy.get("editor-wc").shadow().find(".btn--run");
};

beforeEach(() => {
// intercept request to editor api
cy.intercept(
Expand All @@ -41,60 +51,51 @@ beforeEach(() => {
);
});

it("blocks access to localStorage authKey", () => {
it("blocks access to parent localStorage", () => {
// Arrange
localStorage.clear();
cy.visit(baseUrl);
cy.get("editor-wc")
.shadow()
.find("div[class=cm-content]")
.invoke(
"text",
`<p>authKey: <span id="authKey"></span></p>
<script>
localStorage.setItem("authKey", "secret")
const authKey = localStorage.getItem("authKey")
document.getElementById("authKey").innerHTML = \`\${authKey}\`
</script>`,
);
cy.get("editor-wc").shadow().find(".btn--run").click();
getIframeBody().find("p").should("include.text", "authKey: null");
});
localStorage.setItem("parentKey", "secretValue");

it("blocks access to localStorage OIDC keys", () => {
localStorage.clear();
cy.visit(baseUrl);
cy.get("editor-wc")
.shadow()
.find("div[class=cm-content]")
.invoke(
"text",
`<p>oidcUser: <span id="oidcUser"></span></p>

// Act
const input = getEditorInput();

input.invoke(
"text",
`<p>parentKey: <span id="s"></span></p>
<script>
localStorage.setItem("oidc.user:https://auth-v1.raspberrypi.org:editor-api", "token")
const oidcUser = localStorage.getItem("oidc.user:https://auth-v1.raspberrypi.org:editor-api")
document.getElementById("oidcUser").innerHTML = \`\${oidcUser}\`
const authKey = localStorage.getItem("parentKey")
document.getElementById("s").innerHTML = \`\${authKey}\`
</script>`,
);
cy.get("editor-wc").shadow().find(".btn--run").click();
getIframeBody().find("p").should("include.text", "oidcUser: null");
);
getRunButton().click();

// Assert
getIframeBody().find("p").should("include.text", "parentKey: null");
});

it("allows access to other localStorage keys", () => {
it("allows access to localStorage", () => {
// Arrange
localStorage.clear();
cy.visit(baseUrl);
cy.get("editor-wc")
.shadow()
.find("div[class=cm-content]")
.invoke(
"text",
`<p>foo: <span id="foo"></span></p>

// Act
const input = getEditorInput();

input.invoke(
"text",
`<p>foo: <span id="foo"></span></p>
<script>
localStorage.setItem("foo", "bar")
const foo = localStorage.getItem("foo")
document.getElementById("foo").innerHTML = \`\${foo}\`
</script>`,
);
cy.get("editor-wc").shadow().find(".btn--run").click();
);

getRunButton().click();

// Assert
getIframeBody().find("p").should("include.text", "foo: bar");
});

Expand Down Expand Up @@ -130,17 +131,19 @@ it("updates the preview after a change when you click run", () => {
});

it("blocks non-permitted external links", () => {
// Arrange
localStorage.clear();
cy.visit(baseUrl);
cy.get("editor-wc")
.shadow()
.find("div[class=cm-content]")
.invoke(
"text",
'<a href="https://raspberrypi.org/en/">some external link</a>',
);
cy.get("editor-wc").shadow().find(".btn--run").click();

// Act
getEditorInput().invoke(
"text",
'<a href="https://raspberrypi.org/en/">some external link</a>',
);
getRunButton().click();
getIframeBody().find("a").click();

// Assert
cy.get("editor-wc")
.shadow()
.find("div[class=modal-content__header]")
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
},
"scripts": {
"start": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.config.js",
"start-html-renderer": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.html-renderer.config.js",
"build": "NODE_ENV=production BABEL_ENV=production webpack build -c ./webpack.config.js",
"analyze": "ANALYZE_WEBPACK_BUNDLE=true yarn build",
"lint": "eslint 'src/**/*.{js,jsx}' cypress/**/*.js",
Expand Down
15 changes: 15 additions & 0 deletions public/index-html-renderer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<style>
</style>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="data:," />
<title>Editor HTML preview</title>
</head>
<body>
<div id="root">
</div>
</body>
</html>
186 changes: 186 additions & 0 deletions src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import React, { useCallback, useEffect, useState } from "react";
import { parse } from "node-html-parser";
import mimeTypes from "mime-types";
import {
allowedExternalLinks,
allowedInternalLinks,
matchingRegexes,
} from "../../../../utils/externalLinkHelper";
import "../../../../assets/stylesheets/HtmlRunner.scss";

const parentTag = (node, tag) =>
node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag;

const cssProjectImgs = (projectFile, projectMedia) => {
let updatedProjectFile = { ...projectFile };
if (projectFile.extension === "css") {
projectMedia.forEach((media_file) => {
const find = new RegExp(`['"]${media_file.filename}['"]`, "g"); // prevent substring matches
const replace = `"${media_file.url}"`;
updatedProjectFile.content = updatedProjectFile.content.replaceAll(
find,
replace,
);
});
}
return updatedProjectFile;
};

const getBlobURL = (code, type) => {
const blob = new Blob([code], { type });
return URL.createObjectURL(blob);
};

const replaceHrefNodes = (indexPage, projectMedia, projectCode) => {
const hrefNodes = indexPage.querySelectorAll("[href]");

hrefNodes.forEach((hrefNode) => {
const projectFile = projectCode.find(
(file) => `${file.name}.${file.extension}` === hrefNode.attrs.href,
);

if (hrefNode.attrs?.target === "_blank") {
hrefNode.removeAttribute("target");
}

let onClick;

if (!!projectFile) {
if (parentTag(hrefNode, "head")) {
const projectFileBlob = getBlobURL(
cssProjectImgs(projectFile, projectMedia).content,
mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`),
);
hrefNode.setAttribute("href", projectFileBlob);
} else {
// eslint-disable-next-line no-script-url
hrefNode.setAttribute("href", "javascript:void(0)");
onClick = `window.parent.postMessage({msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }})`;
}
} else {
const matchingExternalHref = matchingRegexes(
allowedExternalLinks,
hrefNode.attrs.href,
);
const matchingInternalHref = matchingRegexes(
allowedInternalLinks,
hrefNode.attrs.href,
);
if (
!matchingInternalHref &&
!matchingExternalHref &&
!parentTag(hrefNode, "head")
) {
// eslint-disable-next-line no-script-url
hrefNode.setAttribute("href", "javascript:void(0)");
onClick = "window.parent.postMessage({msg: 'ERROR: External link'})";
} else if (matchingExternalHref) {
onClick = `window.parent.postMessage({msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }})`;
}
}

if (onClick) {
hrefNode.removeAttribute("target");
hrefNode.setAttribute("onclick", onClick);
}
});
};

const replaceSrcNodes = (
indexPage,
projectMedia,
projectCode,
attr = "src",
) => {
const srcNodes = indexPage.querySelectorAll(`[${attr}]`);

srcNodes.forEach((srcNode) => {
const projectMediaFile = projectMedia.find(
(component) => component.filename === srcNode.attrs[attr],
);
const projectTextFile = projectCode.find(
(file) => `${file.name}.${file.extension}` === srcNode.attrs[attr],
);

let src = "";
if (!!projectMediaFile) {
src = projectMediaFile.url;
} else if (!!projectTextFile) {
src = getBlobURL(
projectTextFile.content,
mimeTypes.lookup(
`${projectTextFile.name}.${projectTextFile.extension}`,
),
);
} else if (matchingRegexes(allowedExternalLinks, srcNode.attrs[attr])) {
src = srcNode.attrs[attr];
}
srcNode.setAttribute(attr, src);
srcNode.setAttribute("crossorigin", true);
});
};

export function HtmlRenderer() {
const [previewHtml, setPreviewHtml] = useState();

const handlePreviewUpdateFromHost = useCallback(
(event) => {
// todo: validate message origin
// todo: use "type" to check what kind of message this is
const message = event.data;
if (message?.current) {
const transformedHtml = parse(message.current);

replaceHrefNodes(transformedHtml, message.media, message.code);
replaceSrcNodes(transformedHtml, message.media, message.code);
replaceSrcNodes(
transformedHtml,
message.media,
message.code,
"data-src",
);

setPreviewHtml(transformedHtml);
}
},
[setPreviewHtml],
);

const handleEventFromPreview = (event) => {
// todo: validate message origin
const message = event.data;
// todo: use "type" to check what kind of message this is
if (typeof event.data?.msg === "string") {
// Forward events originating from the previewed code back to the host
// todo: set appropriate target origin
window.parent.postMessage(message, "*");
}
};

useEffect(() => {
window.addEventListener("message", handlePreviewUpdateFromHost);
window.addEventListener("message", handleEventFromPreview);

const source = window.opener || window.parent;
if (source) {
// todo: set appropriate target origin
source.postMessage({ ready: true }, "*");
}
return () => {
window.removeEventListener("message", handlePreviewUpdateFromHost);
window.removeEventListener("message", handleEventFromPreview);
};
}, [handlePreviewUpdateFromHost]);

return previewHtml ? (
<iframe
className={"htmlrunner-iframe"}
title={"preview-sandbox"}
srcDoc={previewHtml}
/>
) : (
<></>
);
}

export default HtmlRenderer;
Loading
Loading