Load output iframe from a different origin (#968)

* Load output iframe from a different origin

* Update iframe source
This commit is contained in:
Jonatan Kłosko 2022-02-02 00:10:17 +01:00 committed by GitHub
parent 731d95e4f0
commit 178df3dac9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 150 additions and 276 deletions

View file

@ -1,191 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Output</title>
<style>
html, body {
margin: 0;
padding: 0;
font-family: sans-serif;
overflow-y: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
"use strict";
// Invoke the init function in a separate context for better isolation
function applyInit(init, ctx, data) {
init(ctx, data);
}
(() => {
const state = {
token: null,
importPromise: null,
eventHandlers: {},
eventQueue: [],
};
function postMessage(message) {
window.parent.postMessage({ token: state.token, ...message }, "*");
}
const ctx = {
root: document.getElementById("root"),
handleEvent(event, callback) {
if (state.eventHandlers[event]) {
throw new Error(
`Handler has already been defined for event "${event}"`
);
}
state.eventHandlers[event] = callback;
while (
state.eventQueue.length > 0 &&
state.eventHandlers[state.eventQueue[0].event]
) {
const { event, payload } = state.eventQueue.shift();
const handler = state.eventHandlers[event];
handler(payload);
}
},
pushEvent(event, payload = null) {
postMessage({ type: "event", event, payload });
},
importCSS(url) {
return new Promise((resolve, reject) => {
const linkEl = document.createElement("link");
linkEl.addEventListener(
"load",
(event) => {
resolve();
},
{ once: true }
);
linkEl.rel = "stylesheet";
linkEl.href = url;
document.head.appendChild(linkEl);
});
},
};
window.addEventListener("message", (event) => {
if (event.source === window.parent) {
handleParentMessage(event.data);
}
});
function handleParentMessage(message) {
if (message.type === "readyReply") {
state.token = message.token;
onReady();
// Set the base URL for relative URLs
const baseUrlEl = document.createElement("base");
baseUrlEl.href = message.baseUrl;
document.head.appendChild(baseUrlEl);
// We already entered the script and the base URL change
// doesn't impact this import call, so we use the absolute
// URL instead
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
} else if (message.type === "init") {
state.importPromise.then((module) => {
const init = module.init;
if (!init) {
const fns = Object.keys(module);
throw new Error(
`Expected the widget JS module to export an init function, but found: ${fns.join(
", "
)}`
);
}
applyInit(init, ctx, message.data);
});
} else if (message.type === "event") {
const { event, payload } = message;
const handler = state.eventHandlers[event];
if (state.eventQueue.length === 0 && handler) {
handler(payload);
} else {
state.eventQueue.push({ event, payload });
}
}
}
postMessage({ type: "ready" });
function onReady() {
// Report height changes
const resizeObserver = new ResizeObserver((entries) => {
postMessage({ type: "resize", height: document.body.scrollHeight });
});
resizeObserver.observe(document.body);
// Forward relevant DOM events
window.addEventListener("mousedown", (event) => {
postMessage({ type: "domEvent", event: { type: "mousedown" } });
});
window.addEventListener("focus", (event) => {
postMessage({ type: "domEvent", event: { type: "focus" } });
});
window.addEventListener("keydown", (event) => {
if (!isEditableElement(event.target)) {
postMessage({
type: "domEvent",
event: keyboardEventToPayload(event),
});
}
});
}
function isEditableElement(element) {
return element.matches("input, textarea, [contenteditable]");
}
function keyboardEventToPayload(event) {
const {
altKey,
code,
ctrlKey,
isComposing,
key,
location,
metaKey,
repeat,
shiftKey,
} = event;
return {
type: event.type,
props: {
altKey,
code,
ctrlKey,
isComposing,
key,
location,
metaKey,
repeat,
shiftKey,
},
};
}
})();
</script>
</body>
</html>

View file

@ -1,28 +1,5 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { randomToken } from "../lib/utils";
import iframeHtml from "./iframe.html";
const global = {
channel: null,
};
// Returns channel responsible for JS communication in the current session
function getChannel(socket, sessionId, { create = true } = {}) {
if (!global.channel && create) {
global.channel = socket.channel("js_output", { session_id: sessionId });
global.channel.join();
}
return global.channel;
}
export function leaveChannel() {
if (global.channel) {
global.channel.leave();
global.channel = null;
}
}
import { randomToken, sha256Base64 } from "../lib/utils";
/**
* A hook used to render JS-enabled cell output.
@ -69,10 +46,31 @@ const JSOutput = {
this.props.sessionId
);
// When cells/sections are reordered, morphdom detaches and attaches
// the relevant elements in the DOM. Consequently the output element
// becomes temporarily detached from the DOM and attaching it back
// would cause the iframe to reload. This behaviour is expected, see
// https://github.com/whatwg/html/issues/5484 for more details. Reloading
// that frequently is inefficient and also clears the iframe state,
// which makes is very undesired in our case. To solve this, we insert
// the iframe higher in the DOM tree, so that it's never affected by
// reordering. Then, we insert a placeholder element in the output to
// take up the expected space and we use absolute positioning to place
// the iframe exactly over that placeholder. We set up observers to
// track the changes in placeholder's position/size and we keep the
// absolute iframe in sync.
const iframePlaceholder = document.createElement("div");
this.el.appendChild(iframePlaceholder);
const iframe = document.createElement("iframe");
iframe.className = "w-full h-0 absolute z-[1]";
this.state.iframe = iframe;
this.disconnectObservers = bindIframeSize(iframe, iframePlaceholder);
// Register message chandler to communicate with the iframe
function postMessage(message) {
iframe.contentWindow.postMessage(message, "*");
}
@ -136,65 +134,11 @@ const JSOutput = {
};
});
// When cells/sections are reordered, morphdom detaches and attaches
// the relevant elements in the DOM. Consequently the output element
// becomes temporarily detached from the DOM and attaching it back
// would cause the iframe to reload. This behaviour is expected, see
// https://github.com/whatwg/html/issues/5484 for more details. Reloading
// that frequently is inefficient and also clears the iframe state,
// which makes is very undesired in our case. To solve this, we insert
// the iframe higher in the DOM tree, so that it's never affected by
// reordering. Then, we insert a placeholder element in the output to
// take up the expected space and we use absolute positioning to place
// the iframe exactly over that placeholder. We set up observers to
// track the changes in placeholder's position/size and we keep the
// absolute iframe in sync.
const notebookEl = document.querySelector(`[data-element="notebook"]`);
const notebookContentEl = notebookEl.querySelector([
`[data-element="notebook-content"]`,
]);
const iframesEl = notebookEl.querySelector(
`[data-element="output-iframes"]`
);
iframe.className = "w-full h-0 absolute z-[1]";
// Note that we use `srcdoc`, so the iframe has the same origin as the
// parent. For this reason we intentionally don't use allow-same-origin,
// as it would allow the iframe to effectively access the parent window.
iframe.sandbox = "allow-scripts allow-downloads";
iframe.srcdoc = iframeHtml;
iframesEl.appendChild(iframe);
this.el.appendChild(iframePlaceholder);
function repositionIframe() {
const notebookBox = notebookEl.getBoundingClientRect();
const placeholderBox = iframePlaceholder.getBoundingClientRect();
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
iframe.style.top = `${top}px`;
const left =
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
iframe.style.left = `${left}px`;
iframe.style.height = `${placeholderBox.height}px`;
iframe.style.width = `${placeholderBox.width}px`;
}
// Most output position changes are accompanied by changes to the
// notebook content element (adding cells, inserting newlines in
// the editor, etc)
this.resizeObserver = new ResizeObserver((entries) => repositionIframe());
this.resizeObserver.observe(notebookContentEl);
// On lower level cell/section reordering is applied as element
// removal followed by insert, consequently the intersection
// between the output and notebook content changes (becomes none
// for a brief moment)
this.intersectionObserver = new IntersectionObserver(
(entries) => repositionIframe(),
{ root: notebookContentEl }
);
this.intersectionObserver.observe(iframePlaceholder);
// Load the iframe content
const iframesEl = document.querySelector(`[data-element="output-iframes"]`);
initializeIframeSource(iframe).then(() => {
iframesEl.appendChild(iframe);
});
// Event handlers
@ -241,8 +185,7 @@ const JSOutput = {
destroyed() {
window.removeEventListener("message", this.handleWindowMessage);
this.resizeObserver.disconnect();
this.intersectionObserver.disconnect();
this.disconnectObservers();
this.state.iframe.remove();
const channel = getChannel(
@ -270,4 +213,117 @@ function getProps(hook) {
};
}
let channel = null;
/**
* Returns channel used for all JS outputs in the current session.
*/
function getChannel(socket, sessionId, { create = true } = {}) {
if (!channel && create) {
channel = socket.channel("js_output", { session_id: sessionId });
channel.join();
}
return channel;
}
/**
* Leaves the JS outputs channel tied to the current session.
*/
export function leaveChannel() {
if (channel) {
channel.leave();
channel = null;
}
}
/**
* Sets up observers to resize and reposition the iframe
* whenever the placeholder moves.
*/
function bindIframeSize(iframe, iframePlaceholder) {
const notebookEl = document.querySelector(`[data-element="notebook"]`);
const notebookContentEl = notebookEl.querySelector(
`[data-element="notebook-content"]`
);
function repositionIframe() {
const notebookBox = notebookEl.getBoundingClientRect();
const placeholderBox = iframePlaceholder.getBoundingClientRect();
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
iframe.style.top = `${top}px`;
const left = placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
iframe.style.left = `${left}px`;
iframe.style.height = `${placeholderBox.height}px`;
iframe.style.width = `${placeholderBox.width}px`;
}
// Most output position changes are accompanied by changes to the
// notebook content element (adding cells, inserting newlines in
// the editor, etc)
const resizeObserver = new ResizeObserver((entries) => repositionIframe());
resizeObserver.observe(notebookContentEl);
// On lower level cell/section reordering is applied as element
// removal followed by insert, consequently the intersection
// between the output and notebook content changes (becomes none
// for a brief moment)
const intersectionObserver = new IntersectionObserver(
(entries) => repositionIframe(),
{ root: notebookContentEl }
);
intersectionObserver.observe(iframePlaceholder);
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
// Loading iframe using `srcdoc` disables cookies and browser APIs,
// such as camera and microphone (1), the same applies to `src` with
// data URL, so we need to load the iframe through a regular request.
// Since the iframe is sandboxed we also need `allow-same-origin`.
// Additionally, we cannot load the iframe from the same origin as
// the app, because using `allow-same-origin` together with `allow-scripts`
// would be insecure (2). Consequently, we need to load the iframe
// from a different origin.
//
// To ensure integrity of the loaded content we manually verify the
// checksum against the expected value.
//
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
const IFRAME_SHA256 = "9cYdQb4mocxzFoj1EryzubL1n7P+lQTeEdWAkeV4E0I=";
const IFRAME_URL = "https://livebook.space/iframe/v1.html";
function initializeIframeSource(iframe) {
return verifyIframeSource().then(() => {
iframe.sandbox =
"allow-scripts allow-same-origin allow-downloads allow-modals";
iframe.allow =
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
iframe.src = IFRAME_URL;
});
}
let iframeVerificationPromise = null;
function verifyIframeSource() {
if (!iframeVerificationPromise) {
iframeVerificationPromise = fetch(IFRAME_URL)
.then((response) => response.text())
.then((html) => {
if (sha256Base64(html) !== IFRAME_SHA256) {
throw new Error(
"The loaded iframe content doesn't have the expected checksum"
);
}
});
}
return iframeVerificationPromise;
}
export default JSOutput;

View file

@ -1,4 +1,5 @@
import md5 from "crypto-js/md5";
import sha256 from "crypto-js/sha256";
import encBase64 from "crypto-js/enc-base64";
export function isMacOS() {
@ -95,6 +96,14 @@ export function md5Base64(string) {
return md5(string).toString(encBase64);
}
/**
* Calculates SHA256 of the given string and returns
* the base64 encoded binary.
*/
export function sha256Base64(string) {
return sha256(string).toString(encBase64);
}
/**
* A simple throttle version that ensures
* the given function is called at most once