fix: cross site scripting with html content
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
export let code = '';
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-200 dark:bg-gray-500 p-5 rounded-md mb-3">
|
||||
{@html code}
|
||||
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
|
||||
{@html sanitizeHtml(code)}
|
||||
</div>
|
||||
|
||||
+10
-7
@@ -4,6 +4,7 @@
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { questionnaire } from '../store/exercise';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let preview: boolean;
|
||||
@@ -52,31 +53,33 @@
|
||||
{:else if preview}
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$questionnaire.questions.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}
|
||||
</p>
|
||||
{#if $questionnaire.due_by}
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
|
||||
{new Date($questionnaire.due_by).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="mt-3 preview prose prose-sm sm:prose p-2">
|
||||
{@html $questionnaire.description ||
|
||||
$t('course.navItem.lessons.exercises.all_exercises.description.no')}
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html sanitizeHtml(
|
||||
$questionnaire.description ||
|
||||
$t('course.navItem.lessons.exercises.all_exercises.description.no')
|
||||
)}
|
||||
</article>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { lesson } from '../store/lessons';
|
||||
import { browser } from '$app/environment';
|
||||
import { COURSE_TYPE } from '$lib/utils/types';
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let preview: boolean = false;
|
||||
@@ -296,7 +297,7 @@
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||
{@html $questionnaire.description || 'No desription'}
|
||||
{@html sanitizeHtml($questionnaire.description || 'No desription')}
|
||||
</article>
|
||||
|
||||
<PrimaryButton
|
||||
|
||||
+11
-10
@@ -10,6 +10,7 @@
|
||||
import { diffLines } from 'diff';
|
||||
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
@@ -166,7 +167,7 @@
|
||||
|
||||
<aside class="drawer bg-gray-100 dark:bg-neutral-800" class:open>
|
||||
<div class="panel bg-white dark:bg-black">
|
||||
<div class="p-10 w-full pr-80">
|
||||
<div class="w-full p-10 pr-80">
|
||||
<div class="flex items-start gap-x-10">
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={handleDrawerClose}>
|
||||
<ArrowLeft />
|
||||
@@ -180,10 +181,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full h-full flex flex-col items-start">
|
||||
<div class="flex h-full w-full flex-col items-start">
|
||||
<HtmlRender className="m-auto text-center mt-6 flex items-center justify-center">
|
||||
<svelte:fragment slot="content">
|
||||
<h1 class="text-2xl md:text-4xl mt-0 capitalize">
|
||||
<h1 class="mt-0 text-2xl capitalize md:text-4xl">
|
||||
{lessonTitle}
|
||||
</h1>
|
||||
</svelte:fragment>
|
||||
@@ -193,7 +194,7 @@
|
||||
<HtmlRender id="display" className="m-auto">
|
||||
<svelte:fragment slot="content">
|
||||
<div class="amen">
|
||||
{@html content}
|
||||
{@html sanitizeHtml(content)}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</HtmlRender>
|
||||
@@ -203,9 +204,9 @@
|
||||
</div>
|
||||
<div
|
||||
id="scroll-container"
|
||||
class="w-80 fixed right-0 top-0 min-h-screen h-full z-10 overflow-x-auto overflow-y-scroll bg-gray-100 dark:bg-neutral-800 py-10 space-y-6"
|
||||
class="fixed right-0 top-0 z-10 h-full min-h-screen w-80 space-y-6 overflow-x-auto overflow-y-scroll bg-gray-100 py-10 dark:bg-neutral-800"
|
||||
>
|
||||
<p class="font-medium text-xl text-left flex items-start justify-start px-10">
|
||||
<p class="flex items-start justify-start px-10 text-left text-xl font-medium">
|
||||
{$t('course.navItem.lessons.version_history.title')}
|
||||
</p>
|
||||
|
||||
@@ -213,25 +214,25 @@
|
||||
{#each lessonHistory as version, index}
|
||||
<button
|
||||
on:click={() => updateContentVersion(version, index)}
|
||||
class="hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer p-4 w-full px-10 flex items-start {index ==
|
||||
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
|
||||
selectedVersionIndex
|
||||
? 'bg-gray-200 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<CaretRight class="mt-1"></CaretRight>
|
||||
<div>
|
||||
<span class="inline-block font-medium text-base"
|
||||
<span class="inline-block text-base font-medium"
|
||||
>{formatTimestamp(version.timestamp)}</span
|
||||
>
|
||||
{#if index == 0}
|
||||
<span class="block italic text-xs text-start"
|
||||
<span class="block text-start text-xs italic"
|
||||
>{$t('course.navItem.lessons.version_history.current_version')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center justify-start px-10 mt-2 h-10">
|
||||
<div class="mt-2 flex h-10 items-center justify-start px-10">
|
||||
<PrimaryButton className="h-full" isLoading={isMoreHistoryLoading} onClick={loadMoreHistory}
|
||||
>{$t('course.navItem.lessons.version_history.fetch_more_versions')}</PrimaryButton
|
||||
>
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import {
|
||||
lesson,
|
||||
@@ -26,7 +27,7 @@
|
||||
<HtmlRender className="m-auto">
|
||||
<svelte:fragment slot="content">
|
||||
<div>
|
||||
{@html content}
|
||||
{@html sanitizeHtml(content)}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</HtmlRender>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
import pluralize from 'pluralize';
|
||||
import { onMount } from 'svelte';
|
||||
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
|
||||
@@ -101,18 +102,18 @@
|
||||
<div
|
||||
id={feed.id}
|
||||
class="flex flex-col gap-5 {isActive
|
||||
? 'border-2 border-primary-700'
|
||||
: 'border border-gray-200 dark:border-neutral-600'} rounded-md mb-7 max-w-3xl"
|
||||
? 'border-primary-700 border-2'
|
||||
: 'border border-gray-200 dark:border-neutral-600'} mb-7 max-w-3xl rounded-md"
|
||||
>
|
||||
<section>
|
||||
<div class="p-3 pb-0">
|
||||
<div class="flex justify-between mb-2">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="w-9 h-9">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.author?.profile?.avatar_url}
|
||||
alt="users banner"
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
@@ -135,7 +136,7 @@
|
||||
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
|
||||
<svelte:fragment slot="content">
|
||||
<div>
|
||||
{@html feed.content}
|
||||
{@html sanitizeHtml(feed.content)}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</HtmlRender>
|
||||
@@ -150,7 +151,7 @@
|
||||
on:click={() => handleAddNewReaction(reactionType)}
|
||||
class={`flex items-center transition ${
|
||||
feed.reaction[reactionType].length >= 1 &&
|
||||
`${getClassIfSelectedByAuthor(reactionType)} dark:text-black border rounded-full`
|
||||
`${getClassIfSelectedByAuthor(reactionType)} rounded-full border dark:text-black`
|
||||
}`}
|
||||
>
|
||||
<div class="text-[15px]">{reactions[reactionType]}</div>
|
||||
@@ -164,14 +165,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-gray-200 dark:border-neutral-600 p-3">
|
||||
<section class="border-t border-gray-200 p-3 dark:border-neutral-600">
|
||||
{#if feed.comment.length > 0}
|
||||
<button
|
||||
on:click={expandComment}
|
||||
class="flex flex-row items-center gap-1 -mx-2 px-2 rounded-md"
|
||||
class="-mx-2 flex flex-row items-center gap-1 rounded-md px-2"
|
||||
>
|
||||
<UserMultiple size={16} />
|
||||
<p class="text-sm py-2">
|
||||
<p class="py-2 text-sm">
|
||||
{pluralize('comment', feed.comment.length, true)}
|
||||
</p>
|
||||
</button>
|
||||
@@ -179,13 +180,13 @@
|
||||
<div>
|
||||
{#each feed.comment as comment, index}
|
||||
{#if comment.content && (areCommentsExpanded || index === feed.comment.length - 1)}
|
||||
<div class="group flex justify-between items-center py-2">
|
||||
<div class="group flex items-center justify-between py-2">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="w-9 h-9">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={comment.author?.profile?.avatar_url}
|
||||
alt="users banner"
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
@@ -211,11 +212,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="w-7 h-7">
|
||||
<div class="h-7 w-7">
|
||||
<img
|
||||
src={author.avatar_url}
|
||||
alt="users banner"
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -224,7 +225,7 @@
|
||||
bind:value={comment}
|
||||
on:keydown={handleAddNewComment}
|
||||
placeholder="Add class comment"
|
||||
class="w-full bg-transparent border border-gray-200 dark:border-neutral-600 rounded-3xl"
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent dark:border-neutral-600"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
|
||||
export let content = '';
|
||||
export let className = '';
|
||||
export let id = '';
|
||||
@@ -13,6 +14,6 @@
|
||||
{#if $$slots.content}
|
||||
<slot name="content" />
|
||||
{:else}
|
||||
{@html content}
|
||||
{@html sanitizeHtml(content)}
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* HTML Sanitization utilities to prevent XSS attacks
|
||||
*/
|
||||
import DOMPurify from 'dompurify';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content using DOMPurify to prevent XSS attacks
|
||||
* This provides robust client-side protection against XSS attacks
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (typeof html !== 'string') return '';
|
||||
|
||||
// Only run DOMPurify in the browser
|
||||
if (!browser) {
|
||||
return fallbackSanitize(html);
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(html, {
|
||||
// Allow safe HTML tags
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
'span',
|
||||
'div',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'code',
|
||||
'a',
|
||||
'img'
|
||||
],
|
||||
// Allow safe attributes
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
|
||||
// Forbid dangerous attributes
|
||||
FORBID_ATTR: [
|
||||
'onerror',
|
||||
'onload',
|
||||
'onclick',
|
||||
'onmouseover',
|
||||
'onfocus',
|
||||
'onblur',
|
||||
'onchange',
|
||||
'onsubmit',
|
||||
'onreset',
|
||||
'onselect',
|
||||
'onunload'
|
||||
],
|
||||
// Forbid dangerous tags
|
||||
FORBID_TAGS: [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'button',
|
||||
'meta',
|
||||
'link'
|
||||
],
|
||||
// Keep content of forbidden tags
|
||||
KEEP_CONTENT: true,
|
||||
// Sanitize attributes
|
||||
SANITIZE_DOM: true,
|
||||
// Allow data attributes
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// Transform URLs
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback sanitization for server-side rendering
|
||||
*/
|
||||
function fallbackSanitize(html: string): string {
|
||||
if (typeof html !== 'string') return '';
|
||||
|
||||
// Remove script tags and their content
|
||||
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
|
||||
// Remove javascript: protocol from attributes
|
||||
html = html.replace(/javascript:/gi, '');
|
||||
|
||||
// Remove on* event handlers (onclick, onload, onerror, etc.)
|
||||
html = html.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '');
|
||||
html = html.replace(/\s*on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
|
||||
// Remove data: protocol for script execution
|
||||
html = html.replace(/\bdata:\s*text\/html/gi, 'data:text/plain');
|
||||
|
||||
// Remove vbscript: protocol
|
||||
html = html.replace(/vbscript:/gi, '');
|
||||
|
||||
// Remove expression() from CSS (IE specific XSS vector)
|
||||
html = html.replace(/expression\s*\(/gi, '');
|
||||
|
||||
// Remove dangerous tags
|
||||
const dangerousTags = [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'form',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'button'
|
||||
];
|
||||
dangerousTags.forEach((tag) => {
|
||||
const regex = new RegExp(`<${tag}\\b[^>]*>.*?<\\/${tag}>`, 'gi');
|
||||
html = html.replace(regex, '');
|
||||
const selfClosing = new RegExp(`<${tag}\\b[^>]*\\/>`, 'gi');
|
||||
html = html.replace(selfClosing, '');
|
||||
const unclosed = new RegExp(`<${tag}\\b[^>]*>`, 'gi');
|
||||
html = html.replace(unclosed, '');
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips all HTML tags and returns plain text
|
||||
* Use this when you only need the text content
|
||||
*/
|
||||
export function stripHtml(html: string): string {
|
||||
if (typeof html !== 'string') return '';
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS
|
||||
* Use this when displaying user input as text
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/'
|
||||
};
|
||||
|
||||
return text.replace(/[&<>"'/]/g, (s) => map[s]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and sanitizes URLs to prevent javascript: and data: XSS vectors
|
||||
*/
|
||||
export function sanitizeUrl(url: string): string {
|
||||
if (typeof url !== 'string') return '';
|
||||
|
||||
// Remove dangerous protocols
|
||||
if (/^(javascript|vbscript|data|file):/i.test(url.trim())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
Reference in New Issue
Block a user