fix harden preview against self XSS

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-10-09 10:58:02 +02:00
1 parent e6f5e20a61
commit 67cc91d30d
19 files changed
+421 -118

No files matched your search

@@ -158,6 +158,7 @@
import * as vimModule from 'monaco-vim';
import { BiMap } from '$lib/utils/maps';
import { previewQR as generateQR } from '$lib/utils/qrPreview';
import { submitPreviewForm } from '$lib/utils/safePreview.js';
import { vimModeEnabled } from '$lib/store/vimMode.js';
import {
setupVimClipboardIntegration,
@@ -176,6 +177,8 @@
let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled;
let editor = null;
let previewFrame = null;
// unique name so the preview form can target this editor's iframe
let previewFrameName = 'pc-preview-' + Math.random().toString(36).slice(2);
let previewRenderDelayID = null;
let previewRenderDelay = 250;
let isRenderingPreview = false;
@@ -183,7 +186,6 @@
let previousQRHash = 0;
let isPreviewVisible = false;
let externalFrameRef = null;
let fileInputRef;
let shadowContainer = null;
let vimStatusBar = null;
@@ -730,42 +732,23 @@
updatePreview();
};
// create shadow dom for iframe isolation
const createShadowIframe = () => {
// create the named iframe the preview form posts into. the sandboxed server
// response is what isolates the content; the sandbox attribute without
// allow-same-origin adds a second layer. a plain iframe, not a shadow root,
// so the form can find the frame by name.
const createPreviewIframe = () => {
if (!shadowContainer) return;
// clear existing content
shadowContainer.innerHTML = '';
// create shadow root
const shadowRoot = shadowContainer.attachShadow({ mode: 'closed' });
// create iframe inside shadow dom
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock';
iframe.name = previewFrameName;
iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts';
iframe.title = 'preview';
iframe.style.cssText = 'height: 100%; width: 100%; border: none;';
iframe.style.cssText = 'height: 100%; width: 100%; border: none; background: white;';
// add styles to shadow root to isolate it
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
height: 100%;
width: 100%;
background: white;
}
iframe {
height: 100%;
width: 100%;
border: none;
}
`;
shadowContainer.appendChild(iframe);
shadowRoot.appendChild(style);
shadowRoot.appendChild(iframe);
// set as preview frame
previewFrame = iframe;
};
@@ -777,19 +760,15 @@
value = v;
const content = await replaceTemplateVariables(v);
// create shadow iframe if not exists
// create the preview iframe if not exists
if (shadowContainer && !previewFrame) {
createShadowIframe();
createPreviewIframe();
}
// post the content to the backend, which returns it with a sandbox policy
// so the preview runs isolated and still loads its own external resources
if (previewFrame) {
// use data url for null origin isolation
previewFrame.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(content);
}
if (externalFrameRef) {
const embedContent = createEmbed(content);
const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(embedContent);
externalFrameRef.location.replace(dataUrl);
submitPreviewForm(content, previewFrameName);
}
isRenderingPreview = false;
};
@@ -1006,33 +985,14 @@
reader.readAsText(file);
};
const createEmbed = (content) => {
return `
<!DOCTYPE html>
<html>
<head>
<title></title>
<style>
*, body, iframe {margin: 0; padding: 0; border: 0; height: 100%; width: 100%;}
</style>
</head>
<body>
<iframe
sandbox="allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock"
src="data:text/html;charset=utf-8,${encodeURIComponent(content)}"></iframe>
</body>
</html>
`;
};
const openFullPagePreview = async (e) => {
e.preventDefault();
const v = editor.getValue();
value = v;
const content = await replaceTemplateVariables(v);
const blob = new Blob([createEmbed(content)], { type: 'text/html' });
let url = URL.createObjectURL(blob);
externalFrameRef = window.open(url, '_blank');
// open the current content isolated in a new tab through the backend so it
// gets its own sandbox policy and loads its own external resources
submitPreviewForm(content, '_blank');
};
const triggerFileInput = () => {
+123
View File
@@ -0,0 +1,123 @@
// Opens untrusted preview content without ever giving it the admin origin.
// Untrusted markup is posted to the backend preview endpoint, which returns it
// with a sandbox content security policy. The sandbox gives the document an
// opaque origin, so its scripts can not read admin cookies, call the API with
// the admin session, or reach window.opener. Because it is a real server
// response it carries its own policy and does not inherit the strict admin CSP,
// so the preview still loads its own images, styles, fonts and scripts. A data:
// or blob: document would instead inherit the admin CSP and break the preview.
// Types the browser does not run page script for. These open directly so the
// native viewer or download is used. Everything else, including an unknown or
// empty type, is treated as active content and isolated in the sandbox, so a
// mislabeled file errs on the safe side. image/svg+xml is deliberately left out
// because an svg opened as a document can run script.
const INERT_MIME_ALLOWLIST = new Set([
// documents and images that render in a native viewer
'application/pdf',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/bmp',
'image/x-icon',
'image/vnd.microsoft.icon',
// archives and office files, which the browser downloads. office documents
// are zip containers, so they are sniffed as application/zip
'application/zip',
'application/octet-stream',
'application/x-gzip',
'application/gzip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/postscript',
'application/wasm',
// audio and video the browser plays or downloads
'application/ogg',
'audio/aiff',
'audio/midi',
'audio/mpeg',
'audio/ogg',
'audio/wave',
'audio/wav',
'audio/webm',
'video/avi',
'video/mp4',
'video/ogg',
'video/webm',
'video/quicktime',
// fonts
'font/collection',
'font/otf',
'font/ttf',
'font/woff',
'font/woff2'
]);
const baseMime = (mime) => (mime || '').split(';')[0].trim().toLowerCase();
export const PREVIEW_ENDPOINT = '/api/v1/preview';
// Post html to the preview endpoint and navigate the target to the sandboxed
// response. target is a window or frame name, or '_blank' for a new tab. A new
// tab opened this way gets no opener in current browsers, and the response
// sandbox keeps it isolated regardless.
export const submitPreviewForm = (html, target = '_blank') => {
const form = document.createElement('form');
form.method = 'POST';
form.action = PREVIEW_ENDPOINT;
form.target = target;
// a new tab must not keep a handle to the admin window. current browsers
// imply this for a blank target, this makes it explicit where honored.
form.rel = 'noopener noreferrer';
form.style.display = 'none';
const field = document.createElement('textarea');
field.name = 'content';
field.value = String(html ?? '');
form.appendChild(field);
document.body.appendChild(form);
form.submit();
form.remove();
};
const openBlobTab = (content, type) => {
const url = URL.createObjectURL(new Blob([content], { type }));
window.open(url, '_blank', 'noopener,noreferrer');
// noopener gives no window handle to watch for load, so revoke on a timer.
// The new tab reads the blob immediately when it opens.
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
// Open an html string isolated in a new tab through the sandbox endpoint.
export const openIsolatedHtml = (html) => submitPreviewForm(html, '_blank');
const escapeHtml = (s) =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Text types are shown as escaped text inside the sandbox rather than opened as
// a direct blob. A direct text blob is safe in Chrome and Firefox, but some
// browsers up sniff a text blob that contains markup into html. Rendering it as
// escaped text in the opaque sandbox keeps it readable and safe everywhere.
const TEXT_DISPLAY_TYPES = new Set(['text/plain', 'text/csv', 'application/json']);
const openIsolatedText = (text) =>
openIsolatedHtml(
`<!doctype html><meta charset="utf-8"><pre style="white-space:pre-wrap;word-break:break-word">${escapeHtml(text)}</pre>`
);
// Open fetched bytes. Inert types render directly, active or unknown types are
// isolated. bytes may be a Uint8Array, an ArrayBuffer or a string.
export const openPreviewBytes = (bytes, mime) => {
const type = baseMime(mime);
if (TEXT_DISPLAY_TYPES.has(type)) {
const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
openIsolatedText(text);
return;
}
if (INERT_MIME_ALLOWLIST.has(type)) {
openBlobTab(bytes, mime);
return;
}
const html = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
openIsolatedHtml(html);
};
+23
View File
@@ -0,0 +1,23 @@
// Returns the url only when it uses the http or https scheme, otherwise null.
// A stored value like "javascript:..." or "data:..." must never be placed in an
// anchor href or passed to window.open, because a click would run it on the
// admin origin. Use this before rendering any user supplied url as a link.
export const safeExternalHref = (url) => {
if (typeof url !== 'string') {
return null;
}
const trimmed = url.trim();
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
// return the parsed form so the value that was checked is exactly the
// value that gets used. this canonicalizes control characters, backslashes
// and relative forms, leaving no gap between validation and use.
return parsed.href;
}
return null;
};