fix: cross site scripting with html content

This commit is contained in:
Rotimi Best
2025-12-03 09:33:53 +01:00
parent f2b719cd1b
commit 7b18c325da
15 changed files with 458 additions and 151 deletions
+2 -2
View File
@@ -17,10 +17,10 @@
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="stylesheet" href="/carbon-all.css" />
<script defer src="https://umami.hz.oncws.com/script.js"
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
%sveltekit.head%
</head>
@@ -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>
@@ -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
@@ -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
>
@@ -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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
};
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;
}
@@ -0,0 +1,26 @@
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
try {
const report = await request.json();
// Log the CSP violation for monitoring
console.warn('CSP Violation Report:', {
timestamp: new Date().toISOString(),
documentURI: report['document-uri'],
violatedDirective: report['violated-directive'],
blockedURI: report['blocked-uri'],
effectiveDirective: report['effective-directive'],
originalPolicy: report['original-policy'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
columnNumber: report['column-number'],
userAgent: request.headers.get('user-agent')
});
return new Response('OK', { status: 200 });
} catch (error) {
console.error('Error processing CSP report:', error);
return new Response('Bad Request', { status: 400 });
}
};
@@ -26,6 +26,7 @@
import { fetchCourses } from '$lib/utils/services/courses';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
import type { Course } from '$lib/utils/types';
export let data;
@@ -383,20 +384,21 @@
}}
onDelete={() => handleDelete(false)}
/>
<section class="max-w-3xl mx-auto md:mx-10 lg:mb-20">
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
sfgsggs
{#if !question}
<div class="py-10 px-5 mb-3">
<div class="mb-3 px-5 py-10">
<SkeletonText style="width: 25%;" />
<SkeletonText style="width: 100%; margin-bottom: 2rem" />
<SkeletonPlaceholder style="width: 100%; height: 20rem;" />
</div>
{:else}
<div class="py-10 px-5">
<a class="text-gray-500 dark:text-white text-md flex items-center" href={`/lms/community`}>
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`/lms/community`}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex justify-between items-center">
<div class="my-5 flex items-center justify-between">
{#if isEditMode}
<TextField
bind:value={editContent.title}
@@ -404,7 +406,7 @@
errorMessage={errors.title}
/>
<Dropdown
class="w-[25%] h-full"
class="h-full w-[25%]"
size="xl"
label="Select Course"
items={fetchedCourses.map((course) => ({ id: course.id, text: course.title }))}
@@ -418,6 +420,7 @@
disabled={voted.question}
/>
<h2 class="text-3xl">{question.title}</h2>
sfgsg
</div>
{/if}
@@ -439,17 +442,18 @@
{/if}
{/if}
</div>
<div class="my-1 px-1 rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="flex items-center no-underline hover:underline text-black">
<div class="border-1 border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
sfgsg
<Avatar
src={question.author.avatar}
name={question.author.name}
width="w-7"
height="h-7"
/>
<p class="dark:text-white ml-2 text-sm">{question.author.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
</p>
</div>
@@ -475,7 +479,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
{@html sanitizeHtml(question.body)}
</section>
{/if}
</div>
@@ -485,18 +489,18 @@
</div>
{#each question.comments as comment}
<div class="my-5 px-1 flex items-start">
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="w-full rounded-lg border border-1 border-gray">
<header class="flex items-center justify-between leading-none p-2">
<div class="border-1 border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="dark:text-white ml-2 text-sm">{comment.name}</p>
<p class="dark:text-white ml-2 text-sm text-gray-500">
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
@@ -518,7 +522,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
{@html sanitizeHtml(comment.comment)}
</article>
</div>
</div>
@@ -535,7 +539,7 @@
/>
{/if}
<div class="flex justify-end mr-2">
<div class="mr-2 flex justify-end">
<PrimaryButton label="Comment" onClick={submitComment} />
</div>
</div>
@@ -19,6 +19,7 @@
commentInCommunityValidation
} from '$lib/utils/functions/validator';
import { fetchCourses } from '$lib/utils/services/courses';
import { sanitizeHtml } from '$lib/utils/functions/sanitize';
import { currentOrg, currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Course } from '$lib/utils/types';
@@ -477,7 +478,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
{@html sanitizeHtml(question.body)}
</section>
{/if}
</div>
@@ -520,7 +521,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
{@html sanitizeHtml(comment.comment)}
</article>
</div>
</div>