fix(security): sandbox preview iframes to prevent XSS

Add sandbox='allow-scripts' to all preview iframes. This isolates
user-executed code from the parent page's localStorage (auth tokens),
cookies, and DOM. Switch from document.write() to srcdoc attribute
since sandboxed iframes can't use document.write().

Addresses SEC-1 (critical) from security audit.
This commit is contained in:
2026-03-28 16:38:56 +01:00
parent 253e61d75d
commit 743060f71b

View File

@@ -216,18 +216,18 @@ export class LessonEngine {
iframe.style.height = "100%"; iframe.style.height = "100%";
iframe.style.border = "none"; iframe.style.border = "none";
iframe.title = "Preview"; iframe.title = "Preview";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById(previewContainer || "preview-area"); const container = document.getElementById(previewContainer || "preview-area");
container.innerHTML = ""; container.innerHTML = "";
container.appendChild(iframe); container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; let html;
iframeDoc.open();
if (mode === "html" || mode === "playground") { if (mode === "html" || mode === "playground") {
// For HTML/playground mode, user code IS the HTML content (may include <style> blocks) // For HTML/playground mode, user code IS the HTML content (may include <style> blocks)
const userHtml = this.userCode || ""; const userHtml = this.userCode || "";
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -239,11 +239,11 @@ export class LessonEngine {
${userHtml} ${userHtml}
</body> </body>
</html> </html>
`); `;
} else if (mode === "tailwind") { } else if (mode === "tailwind") {
// For Tailwind mode, user code goes directly in HTML classes // For Tailwind mode, user code goes directly in HTML classes
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode); const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -256,11 +256,11 @@ export class LessonEngine {
${htmlWithClasses} ${htmlWithClasses}
</body> </body>
</html> </html>
`); `;
} else if (mode === "markdown") { } else if (mode === "markdown") {
// For Markdown mode, parse user code to HTML // For Markdown mode, parse user code to HTML
const renderedHtml = marked.parse(this.userCode || ""); const renderedHtml = marked.parse(this.userCode || "");
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -288,11 +288,11 @@ export class LessonEngine {
${renderedHtml} ${renderedHtml}
</body> </body>
</html> </html>
`); `;
} else { } else {
// Original CSS mode // Original CSS mode
const userCssWithWrapper = this.getCompleteCss(); const userCssWithWrapper = this.getCompleteCss();
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -305,10 +305,10 @@ export class LessonEngine {
${previewHTML} ${previewHTML}
</body> </body>
</html> </html>
`); `;
} }
iframeDoc.close(); iframe.srcdoc = html;
} }
injectTailwindClasses(html, userClasses) { injectTailwindClasses(html, userClasses) {
@@ -341,6 +341,7 @@ export class LessonEngine {
iframe.style.height = "100%"; iframe.style.height = "100%";
iframe.style.border = "none"; iframe.style.border = "none";
iframe.title = "Expected Result"; iframe.title = "Expected Result";
iframe.setAttribute("sandbox", "allow-scripts");
const container = document.getElementById("preview-expected"); const container = document.getElementById("preview-expected");
if (!container) return; if (!container) return;
@@ -348,12 +349,11 @@ export class LessonEngine {
container.innerHTML = ""; container.innerHTML = "";
container.appendChild(iframe); container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; let html;
iframeDoc.open();
if (mode === "html" || mode === "playground") { if (mode === "html" || mode === "playground") {
// For HTML/playground mode, solution code IS the HTML content // For HTML/playground mode, solution code IS the HTML content
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -365,11 +365,11 @@ export class LessonEngine {
${solutionCode} ${solutionCode}
</body> </body>
</html> </html>
`); `;
} else if (mode === "tailwind") { } else if (mode === "tailwind") {
// For Tailwind mode, inject solution classes into HTML // For Tailwind mode, inject solution classes into HTML
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode); const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -382,11 +382,11 @@ export class LessonEngine {
${htmlWithClasses} ${htmlWithClasses}
</body> </body>
</html> </html>
`); `;
} else if (mode === "markdown") { } else if (mode === "markdown") {
// For Markdown mode, parse solution to HTML // For Markdown mode, parse solution to HTML
const renderedHtml = marked.parse(solutionCode || ""); const renderedHtml = marked.parse(solutionCode || "");
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -414,12 +414,12 @@ export class LessonEngine {
${renderedHtml} ${renderedHtml}
</body> </body>
</html> </html>
`); `;
} else { } else {
// CSS mode - wrap solution with prefix/suffix // CSS mode - wrap solution with prefix/suffix
const { codePrefix, codeSuffix } = this.currentLesson; const { codePrefix, codeSuffix } = this.currentLesson;
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`; const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
iframeDoc.write(` html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -432,10 +432,10 @@ export class LessonEngine {
${previewHTML} ${previewHTML}
</body> </body>
</html> </html>
`); `;
} }
iframeDoc.close(); iframe.srcdoc = html;
} }
/** /**