fix: address security findings from final review
- Replace regex HTML sanitization with DOMPurify in NoteWidget (XSS fix) - Remove allow-same-origin from default iframe sandbox in EmbedWidget - Add URL scheme validation for embed URLs (http/https only) - Install isomorphic-dompurify dependency
This commit is contained in:
Generated
+734
-3
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bits-ui": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-svelte": "^0.469.0",
|
||||
"marked": "^17.0.5",
|
||||
|
||||
@@ -14,7 +14,22 @@
|
||||
let loading = $state(true);
|
||||
|
||||
const iframeHeight = $derived(config.height || 300);
|
||||
const sandboxValue = $derived(config.sandbox || 'allow-scripts allow-same-origin');
|
||||
// Default sandbox: allow-scripts only. allow-same-origin is intentionally omitted
|
||||
// because combining both allows the embedded page to escape the sandbox entirely.
|
||||
const sandboxValue = $derived(config.sandbox || 'allow-scripts');
|
||||
|
||||
// Only allow http/https URLs — block javascript:, data:, etc.
|
||||
const safeUrl = $derived.by(() => {
|
||||
try {
|
||||
const parsed = new URL(config.url);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return config.url;
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleLoad() {
|
||||
loading = false;
|
||||
@@ -23,28 +38,34 @@
|
||||
|
||||
<div class="flex flex-col rounded-xl border border-border bg-card">
|
||||
<div class="relative" style="height: {iframeHeight}px;">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
{#if !safeUrl}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Invalid or blocked embed URL
|
||||
</div>
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<iframe
|
||||
src={safeUrl}
|
||||
title="Embedded content"
|
||||
sandbox={sandboxValue}
|
||||
class="h-full w-full rounded-xl border-0"
|
||||
onload={handleLoad}
|
||||
></iframe>
|
||||
{/if}
|
||||
<iframe
|
||||
src={config.url}
|
||||
title="Embedded content"
|
||||
sandbox={sandboxValue}
|
||||
class="h-full w-full rounded-xl border-0"
|
||||
onload={handleLoad}
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
interface NoteConfig {
|
||||
content: string;
|
||||
@@ -12,7 +13,6 @@
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
// Configure marked for security
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
@@ -20,24 +20,22 @@
|
||||
|
||||
const renderedContent = $derived.by(() => {
|
||||
if (config.format === 'text') {
|
||||
return config.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(
|
||||
config.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
);
|
||||
}
|
||||
// Sanitize by stripping script tags and event handlers from markdown output
|
||||
const raw = marked.parse(config.content, { async: false }) as string;
|
||||
return raw
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/\s*on\w+\s*=\s*"[^"]*"/gi, '')
|
||||
.replace(/\s*on\w+\s*=\s*'[^']*'/gi, '');
|
||||
return DOMPurify.sanitize(raw);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- content is sanitized above -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user