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

+3 -17
View File
@@ -4,23 +4,9 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// prevent white flash in dark mode
(function () {
try {
const storedTheme = localStorage.getItem('theme-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
document.documentElement.style.backgroundColor = '#111827';
}
} catch (e) {
// ignore errors
}
})();
</script>
<!-- served from the app origin so it passes the CSP script-src 'self';
classic blocking script so it runs before first paint -->
<script src="%sveltekit.assets%/theme-init.js"></script>
%sveltekit.head%
</head>
@@ -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;
};
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import { openPreviewBytes } from '$lib/utils/safePreview.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -525,11 +526,12 @@
for (let i = 0; i < binaryData.length; i++) {
byteArray[i] = binaryData.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: res.data.mimeType });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// isolate html and other active content in a sandbox so a preview
// can not run script on the admin origin
openPreviewBytes(byteArray, res.data.mimeType);
} else {
window.open(`https://${$page.params.domain}/${path}`, '_blank');
// a real domain asset opens on the phishing origin, separate from admin
window.open(`https://${$page.params.domain}/${path}`, '_blank', 'noopener,noreferrer');
}
};
+4 -3
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import { openPreviewBytes } from '$lib/utils/safePreview.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -280,9 +281,9 @@
for (let i = 0; i < binaryData.length; i++) {
byteArray[i] = binaryData.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: res.data.mimeType });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// isolate html and other active content in a sandbox so a preview
// can not run script on the admin origin
openPreviewBytes(byteArray, res.data.mimeType);
} catch (e) {
addToast('Failed to get attachment content', 'Error');
console.error('failed to get attachment content', e);
@@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import { safeExternalHref } from '$lib/utils/url.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -839,14 +840,22 @@
</TableCell>
<TableCell>
{#if template.afterLandingPageRedirectURL}
<a
href={`${template.afterLandingPageRedirectURL}`}
target="_blank"
class="block w-full py-1"
title={template.afterLandingPageRedirectURL}
>
{template.afterLandingPageRedirectURL}
</a>
{@const redirectHref = safeExternalHref(template.afterLandingPageRedirectURL)}
{#if redirectHref}
<a
href={redirectHref}
target="_blank"
rel="noopener noreferrer"
class="block w-full py-1"
title={template.afterLandingPageRedirectURL}
>
{template.afterLandingPageRedirectURL}
</a>
{:else}
<span class="block w-full py-1" title={template.afterLandingPageRedirectURL}>
{template.afterLandingPageRedirectURL}
</span>
{/if}
{/if}
</TableCell>
<TableCellCheck value={template.isUsable} />
@@ -6,6 +6,7 @@
import SubHeadline from '$lib/components/SubHeadline.svelte';
import { addToast } from '$lib/store/toast';
import { BiMap } from '$lib/utils/maps';
import { openIsolatedHtml } from '$lib/utils/safePreview.js';
import { fetchAllRows } from '$lib/utils/api-utils';
import { AppStateService } from '$lib/service/appState';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
@@ -699,10 +700,9 @@
throw res.error;
}
// open email in new tab as a blob
const blob = new Blob([res.data], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// open the rendered email isolated in a sandbox so its markup can
// not run script on the admin origin
openIsolatedHtml(res.data);
// clear pending recipient
pendingEmailPreviewRecipient = null;
@@ -3,6 +3,7 @@
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { safeExternalHref } from '$lib/utils/url.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
@@ -491,13 +492,20 @@
showIsLoading();
const res = await api.oauthProvider.getAuthorizationURL(id);
if (res.success && res.data.authorizationURL) {
// the url comes from the provider discovery document, so only open
// it when it is http or https, never a javascript or data scheme
const authURL = safeExternalHref(res.data.authorizationURL);
if (!authURL) {
addToast('Provider returned an invalid authorization URL', 'Error');
return;
}
// open authorization url in popup window
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
res.data.authorizationURL,
authURL,
'OAuth Authorization',
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`
);
+18
View File
@@ -0,0 +1,18 @@
// Runs before the app renders to prevent a white flash in dark mode. Kept as a
// file served from the app origin, not an inline script, so it is allowed by the
// Content Security Policy script-src 'self' without an inline hash. Loaded as a
// classic blocking script in the head so it runs before first paint.
(function () {
try {
const storedTheme = localStorage.getItem('theme-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
document.documentElement.style.backgroundColor = '#111827';
}
} catch (e) {
// ignore errors
}
})();
+23 -13
View File
@@ -13,20 +13,30 @@ export default {
alias: {
$lib: './src/lib',
'$lib/*': './src/lib/*'
}
// magic for dynamic routes to render at build time
// creates one page for each word in the dictionary
/*
prerender: {
crawl: true,
entries: ['/']
},
*/
/* TODO
// Hash mode lets SvelteKit add the hash of its own inline start script, so
// the app boots under script-src 'self' with no unsafe-inline. style-src
// keeps unsafe-inline because Monaco and Svelte inject styles at runtime
// that can not be hashed. blob: and data: are allowed only where previews
// and Monaco workers need them, never for script. frame-ancestors and
// form-action can not be set from a meta tag, so they live in the Go
// security headers middleware instead.
csp: {
mode: "hash",
directives: { "script-src": ["self"] },
},
*/
mode: 'hash',
directives: {
'default-src': ['self'],
'script-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:'],
'font-src': ['self', 'data:'],
'connect-src': ['self'],
'worker-src': ['self', 'blob:'],
// previews load from the same origin preview endpoint, not data: or
// blob: documents, so frame-src only needs self
'frame-src': ['self'],
'object-src': ['none'],
'base-uri': ['self']
}
}
}
};