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",
|
"bcryptjs": "^2.4.3",
|
||||||
"bits-ui": "^1.3.0",
|
"bits-ui": "^1.3.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"isomorphic-dompurify": "^3.7.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.469.0",
|
"lucide-svelte": "^0.469.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
|
|||||||
@@ -14,7 +14,22 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
const iframeHeight = $derived(config.height || 300);
|
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() {
|
function handleLoad() {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -23,6 +38,11 @@
|
|||||||
|
|
||||||
<div class="flex flex-col rounded-xl border border-border bg-card">
|
<div class="flex flex-col rounded-xl border border-border bg-card">
|
||||||
<div class="relative" style="height: {iframeHeight}px;">
|
<div class="relative" style="height: {iframeHeight}px;">
|
||||||
|
{#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}
|
{#if loading}
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-muted/50">
|
<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">
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -40,11 +60,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<iframe
|
<iframe
|
||||||
src={config.url}
|
src={safeUrl}
|
||||||
title="Embedded content"
|
title="Embedded content"
|
||||||
sandbox={sandboxValue}
|
sandbox={sandboxValue}
|
||||||
class="h-full w-full rounded-xl border-0"
|
class="h-full w-full rounded-xl border-0"
|
||||||
onload={handleLoad}
|
onload={handleLoad}
|
||||||
></iframe>
|
></iframe>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
interface NoteConfig {
|
interface NoteConfig {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -12,7 +13,6 @@
|
|||||||
|
|
||||||
let { config }: Props = $props();
|
let { config }: Props = $props();
|
||||||
|
|
||||||
// Configure marked for security
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true
|
gfm: true
|
||||||
@@ -20,24 +20,22 @@
|
|||||||
|
|
||||||
const renderedContent = $derived.by(() => {
|
const renderedContent = $derived.by(() => {
|
||||||
if (config.format === 'text') {
|
if (config.format === 'text') {
|
||||||
return config.content
|
return DOMPurify.sanitize(
|
||||||
|
config.content
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/\n/g, '<br>');
|
.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;
|
const raw = marked.parse(config.content, { async: false }) as string;
|
||||||
return raw
|
return DOMPurify.sanitize(raw);
|
||||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
||||||
.replace(/\s*on\w+\s*=\s*"[^"]*"/gi, '')
|
|
||||||
.replace(/\s*on\w+\s*=\s*'[^']*'/gi, '');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
<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">
|
<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}
|
{@html renderedContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user