mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-13 18:50:47 +02:00
fix harden preview against self XSS
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
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 = () => {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user