Förderjahr 2018 / Project Call #13 / ProjektID: 3361 / Projekt: Hedgehog Cloud
Im vorhergehenden Post haben wir erläutert, welche Abwägungen für die Programmausführung in der Hedgehog IDE wichtig sind, und dass JavaScript als Anfangspunkt die besten Voraussetzungen für eine offlinefähige Web-Entwicklungsumgebung bietet. In diesem Posts wirds konkret: wie funktioniert die Code-Ausführung denn jetzt wirklich?
Interpretiert
Dazu haben wir uns zwei Varianten angeschaut. Da wir in der Hedgehog IDE auch Blockly benutzen, haben wir nachgelesen, was denn Google so für die Ausführung empfiehlt, und sind dabei auf JS interpreter gestoßen. Wie der Name vermuten lässt handelt es sich dabei um einen JavaScript Interpreter, geschrieben in JavaScript. Das praktische an der Sache ist, dass der Interpreter volle Kontrolle über die Ausführung hat, was zum Beispiel schrittweise Ausführung ermöglicht.
Das ist an sich zwar toll, aber im letzten Post haben wir ja schon erwähnt:
Manche Sprachen haben auch webbasierte Interpreter, aber auch diese haben oft Nachteile: sie sind langsamer, nicht ganz kompatibel mit dem Original, und/oder groß und damit langsam herunterzuladen.
Ohne dem JS Interpreter konkret etwas zu unterstellen - die Idee, zuerst JavaScript in der Hedgehog IDE zu unterstützen, war es doch, keinen separaten Interpreter zu brauchen. Die Schritt-für-Schritt Ausführung ist ein tolles Feature, aber als erstes soll es doch der Browser selbst machen. Das heißt aber, wir müssen für die notwendige Isolation zwischen der Hedgehog IDE und dem Code unserer Nutzer sorgen.
iframes machen das!
Nehmen wir als Beispiel das einfachste JavaScript-Programm her, das wir schreiben können:
console.log("Hello World");
Sicherheit geht vor
Wir wollen das jetzt ausführen, aber abgeschirmt von der Hedgehog IDE selbst. Dazu gibt es in der Web-Welt praktischerweise genau das passende Tool, und zwar iframes. "inline frames" dienen dazu, HTML-Seiten in andere HTML-Seiten einzubetten. Wenn beide Seiten aber von der gleichen Domain stammen, erlaubt der Browser immer noch einiges an Zugriff zwischen den beiden Seiten, aber auch das können wir dem Browser mit dem "sandbox"-Attribut abgewöhnen. Das sieht dann zum Beispiel etwa so aus:
<iframe src="..." sandbox="allow-scripts" style="display: none;" />
JavaScript ist dem iframe erlaubt und er ist versteckt; alles andere potenziell nervige, wie etwa Popups öffnen, ist dem iframe verboten. Ganz wesentlich ist natürlich was der iframe tun soll, und das sagen wir ihm über postMessage.
Im äußeren Frame:
// hier muss später die tatsächliche URL der Web-Anwendung stehen
const ORIGIN = '*';
// onload wird aufgerufen, wenn der iframe geladen hat, also auch bereit ist Nachrichten zu empfangen
child.onload = () => {
// hier sagen wir dem iframe was!
child.contentWindow.postMessage(data, ORIGIN);
};
Im inneren Frame:
// hier muss später die tatsächliche URL der Web-Anwendung stehen
const ORIGIN = '*';
const receiveMessage = ({ data, origin, source }) => {
// das muss normalerweise aktiviert sein!!
// if (origin !== ORIGIN) return;
// jetzt am besten irgendwas mit data machen!
};
// anfangen dem äußeren Frame zuzuhören
window.addEventListener('message', receiveMessage, false);
Umgekehrt - dass der innere auch dem äußeren Frame Nachrichten schicken kann - geht es fast genau gleich. Mit einer kleinen Ausnahme: die origin, die wir zur Sicherheit beim Empfang von Nachrichten überprüft haben, ist bei einem sandbox iframe ohne der "allow-same-origin" Berechtigung immer "null". Deswegen muss der äußere Frame sich anders helfen, um den sendenden iframe korrekt zu identifizieren, das geht so:
const receiveMessage = ({ data, origin, source }) => {
if (origin !== 'null' || source !== child.contentWindow)
return;
// jetzt am besten irgendwas mit data machen!
};
... und das waren alle notwendigen Sicherheitsvorkehrungen! Um nochmal zusammenzufassen:
- iframes, die das "sandbox"-Attribut haben sind perfekt von ihrer Umgebung abgeschirmt und deshalb perfekt für nicht vertrauenswürdigen Code geeignet. Dafür geben wir dem iframe die "allow-scripts"-Berechtigung, aber keinesfalls die "allow-same-origin"-Berechtigung.
- Zur Kommunikation gibt es postMessage und das message-Event. Es ist unbedingt notwendig, den Absender einer Nachricht zu überprüfen, weil Nachrichten von jedem Browser-Tab oder iframe an jeden anderen gesendet werden können!
- Normale Frames haben eine origin, das ist die URL der Webanwendung, über die man den Absender identifizieren kann.
- Ohne "allow-same-origin" hat ein sandbox-iframe keine origin, weshalb in diesem Fall die genaue Identität des sendenden iframes überprüft werden muss.
Endlich Hello World
Mit der Security erledigt ist der Rest dann nicht mehr schwer, wir müssen nur noch dafür sorgen, dass wir den auszugebenden Text abfangen. Das geht im iframe denkbar einfach:
window.console.log = text => parent.postMessage({command: 'print', payload: text });
Im äußeren frame warten wir auf die Nachrichten und können damit machen was wir wollen!
Der gesamte Code
Für alle die das ganze selbst ausprobieren wollen gibt es hier den kompletten Code:
Äußerer Frame: index.html
<html>
<head>
</head>
<body>
<div id="mountpoint">
</div>
<div id="outputs">
</div>
<script type="text/javascript">
(() => {
// when deployed, this is set to the actual URL of the web application
const ORIGIN = '*';
// this will store the child frame for communication
let child = null;
const handlers = {
print: (source, text) => {
// later on, the text can be shown somewhere else
console.log(text);
},
exit: (source, error) => {
// later on, the error can be shown somewhere else
if (error) console.error("in child frame:", error);
child.remove();
child = null;
},
};
const receiveMessage = ({ data, origin, source }) => {
// a sandboxed frame without allow-same-origin must be checked via the source window
if (origin !== 'null' || source !== child.contentWindow)
return;
const { command, payload } = data;
const handler = handlers[command];
if (handler) {
handler(source, payload);
}
};
window.addEventListener('message', receiveMessage, false);
// creates a child iframe
const execute = code => {
child = document.createElement('iframe');
child.setAttribute('src', 'child.html');
child.setAttribute('sandbox', 'allow-scripts');
child.style.display = 'none';
document.getElementById('mountpoint').appendChild(child);
const sendMessage = (command, payload) => {
child.contentWindow.postMessage({ command, payload }, ORIGIN);
};
child.onload = () => {
sendMessage('execute', code);
};
};
execute(`
console.log('this is the child!');
`);
})();
</script>
</body>
</html>
Innerer Frame: child.html
<html>
<head>
</head>
<body>
<script type="text/javascript">
(() => {
// when deployed, this is set to the actual URL of the web application
const ORIGIN = '*';
// this will store the parent frame for communication
let parent = null;
// talk to the parent frame
const sendMessage = (command, payload) => {
parent.postMessage({ command, payload }, ORIGIN);
};
// exported APIs for the client function
window.console.log = text => sendMessage('print', text);
// message listener & handlers
const handlers = {
execute: (source, code) => {
// store the frame for later
parent = source;
const fn = new Function(code);
try {
fn();
sendMessage('exit');
} catch (error) {
// we can't send the error to the outer frame, but we can send a string
sendMessage('exit', error.toString());
// still print a stack trace
throw error;
}
},
};
const receiveMessage = ({ data, origin, source }) => {
// activate this when deploying
// if (origin !== ORIGIN) return;
const { command, payload } = data;
const handler = handlers[command];
if (handler) {
handler(source, payload);
}
};
window.addEventListener('message', receiveMessage, false);
})();
</script>
</body>
</html>