mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-12 01:58:53 +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
+3
-17
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user