feat(sanitization): integrate DOMPurify for HTML sanitization across components

- Added DOMPurify library to sanitize HTML content in toast notifications and other components to prevent XSS vulnerabilities.
- Updated relevant components to use the new `sanitizeHTML` function for safe rendering of HTML content.
- Ensured that only allowed tags and attributes are permitted in sanitized output.
This commit is contained in:
Andras Bacsai
2025-08-19 10:34:54 +02:00
parent f02c36985f
commit 6727fd958f
8 changed files with 77 additions and 44 deletions

3
public/js/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
popToast(custom) {
let html = '';
if (typeof custom != 'undefined') {
html = custom;
html = window.sanitizeHTML(custom);
}
toast(this.title, { description: this.description, type: this.type, position: this.position, html: html })
}
@@ -96,27 +96,27 @@
topToast.style.top = '0px';
}
}
let bottomPositionOfFirstToast = this.getBottomPositionOfElement(topToast);
if (this.toasts.length == 1) return;
let middleToast = document.getElementById(this.toasts[1].id);
middleToast.style.zIndex = 90;
if (this.expanded) {
middleToastPosition = topToast.getBoundingClientRect().height +
this.paddingBetweenToasts + 'px';
if (this.position.includes('bottom')) {
middleToast.style.top = 'auto';
middleToast.style.bottom = middleToastPosition;
} else {
middleToast.style.top = middleToastPosition;
}
middleToast.style.scale = '100%';
middleToast.style.transform = 'translateY(0px)';
} else {
middleToast.style.scale = '94%';
if (this.position.includes('bottom')) {
@@ -126,8 +126,8 @@
middleToast.style.transform = 'translateY(16px)';
}
}
if (this.toasts.length == 2) return;
let bottomToast = document.getElementById(this.toasts[2].id);
bottomToast.style.zIndex = 80;
@@ -136,14 +136,14 @@
this.paddingBetweenToasts +
middleToast.getBoundingClientRect().height +
this.paddingBetweenToasts + 'px';
if (this.position.includes('bottom')) {
bottomToast.style.top = 'auto';
bottomToast.style.bottom = bottomToastPosition;
} else {
bottomToast.style.top = bottomToastPosition;
}
bottomToast.style.scale = '100%';
bottomToast.style.transform = 'translateY(0px)';
} else {
@@ -155,9 +155,9 @@
bottomToast.style.transform = 'translateY(32px)';
}
}
if (this.toasts.length == 3) return;
let burnToast = document.getElementById(this.toasts[3].id);
burnToast.style.zIndex = 70;
@@ -168,14 +168,14 @@
this.paddingBetweenToasts +
bottomToast.getBoundingClientRect().height +
this.paddingBetweenToasts + 'px';
if (this.position.includes('bottom')) {
burnToast.style.top = 'auto';
burnToast.style.bottom = burnToastPosition;
} else {
burnToast.style.top = burnToastPosition;
}
burnToast.style.scale = '100%';
burnToast.style.transform = 'translateY(0px)';
} else {
@@ -183,40 +183,40 @@
this.alignBottom(topToast, burnToast);
burnToast.style.transform = 'translateY(48px)';
}
burnToast.firstElementChild.classList.remove('opacity-100');
burnToast.firstElementChild.classList.add('opacity-0');
let that = this;
// Burn 🔥 (remove) last toast
setTimeout(function() {
that.toasts.pop();
}, 300);
if (this.position.includes('bottom')) {
middleToast.style.top = 'auto';
}
return;
},
alignBottom(element1, element2) {
// Get the top position and height of the first element
let top1 = element1.offsetTop;
let height1 = element1.offsetHeight;
// Get the height of the second element
let height2 = element2.offsetHeight;
// Calculate the top position for the second element
let top2 = top1 + (height1 - height2);
// Apply the calculated top position to the second element
element2.style.top = top2 + 'px';
},
alignTop(element1, element2) {
// Get the top position of the first element
let top1 = element1.offsetTop;
// Apply the same top position to the second element
element2.style.top = top1 + 'px';
},
@@ -244,13 +244,13 @@
$el.style.height = '0px';
return;
}
lastToast = this.toasts[this.toasts.length - 1];
lastToastRectangle = document.getElementById(lastToast.id).getBoundingClientRect();
firstToast = this.toasts[0];
firstToastRectangle = document.getElementById(firstToast.id).getBoundingClientRect();
if (this.toastsHovered) {
if (this.position.includes('bottom')) {
$el.style.height = ((firstToastRectangle.top + firstToastRectangle.height) - lastToastRectangle.top) + 'px';
@@ -276,13 +276,16 @@
if(event.detail.position){
position = event.detail.position;
}
// Sanitize HTML content to prevent XSS
let sanitizedHtml = event.detail.html ? window.sanitizeHTML(event.detail.html) : '';
toasts.unshift({
id: 'toast-' + Math.random().toString(16).slice(2),
show: false,
message: event.detail.message,
description: event.detail.description,
type: event.detail.type,
html: event.detail.html
html: sanitizedHtml
});
"
@mouseenter="toastsHovered=true;" @mouseleave="toastsHovered=false" x-init="if (layout == 'expanded') {
@@ -356,9 +359,9 @@
}, 2000)
}
});
setTimeout(function() {
setTimeout(function() {
if (position.includes('bottom')) {
$el.firstElementChild.classList.remove('opacity-0', 'translate-y-full');
@@ -366,13 +369,13 @@
$el.firstElementChild.classList.remove('opacity-0', '-translate-y-full');
}
$el.firstElementChild.classList.add('opacity-100', 'translate-y-0');
setTimeout(function() {
stackToasts();
}, 10);
}, 5);
}, 50);
this.timeout = setTimeout(function() {
setTimeout(function() {
$el.firstElementChild.classList.remove('opacity-100');
@@ -421,16 +424,16 @@
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9996 7C12.5519 7 12.9996 7.44772 12.9996 8V12C12.9996 12.5523 12.5519 13 11.9996 13C11.4474 13 10.9996 12.5523 10.9996 12V8C10.9996 7.44772 11.4474 7 11.9996 7ZM12.001 14.99C11.4488 14.9892 11.0004 15.4363 10.9997 15.9886L10.9996 15.9986C10.9989 16.5509 11.446 16.9992 11.9982 17C12.5505 17.0008 12.9989 16.5537 12.9996 16.0014L12.9996 15.9914C13.0004 15.4391 12.5533 14.9908 12.001 14.99Z"
fill="currentColor"></path>
</svg>
<p class="text-black leading-2 dark:text-neutral-200" x-html="toast.message">
<p class="text-black leading-2 dark:text-neutral-200" x-text="toast.message">
</p>
</div>
<div x-show="toast.description" :class="{ 'pl-5': toast.type!='default' }"
class="mt-1.5 text-xs px-2 opacity-90 whitespace-pre-wrap w-full break-words"
x-html="toast.description"></div>
x-html="window.sanitizeHTML(toast.description)"></div>
</div>
</template>
<template x-if="toast.html">
<div x-html="toast.html"></div>
<div x-html="window.sanitizeHTML(toast.html)"></div>
</template>
<span class="absolute mt-1 text-xs right-[4.4rem] text-success font-bold"
x-show="copyNotification"

View File

@@ -35,9 +35,9 @@
@endphp
<title>{{ $name }}{{ $title ?? 'Coolify' }}</title>
@env('local')
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" />
@else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" />
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" />
@else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" />
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/js/app.js', 'resources/css/app.css'])
@@ -54,6 +54,7 @@
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/apexcharts.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/purify.min.js') }}"></script>
@endauth
</head>
@section('body')
@@ -61,6 +62,32 @@
<body>
<x-toast />
<script data-navigate-once>
// Global HTML sanitization function using DOMPurify
window.sanitizeHTML = function(html) {
if (!html) return '';
// Use DOMPurify with strict configuration for toast notifications
const purified = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span',
'strong', 'u'
],
ALLOWED_ATTR: ['class', 'href', 'target', 'title'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button',
'select', 'textarea', 'details', 'summary', 'dialog', 'style'
],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
'onsubmit', 'ontoggle', 'style'
],
KEEP_CONTENT: true,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
SANITIZE_DOM: true
});
console.log(purified);
return purified;
};
if (!('theme' in localStorage)) {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark')

View File

@@ -4,7 +4,7 @@
</x-slot>
<form wire:submit='submit' class="flex flex-col pb-10">
<div class="flex gap-2">
<h1>Project: {{ data_get($project, 'name') }}</h1>
<h1>Project: {{ data_get_str($project, 'name')->limit(15) }}</h1>
<div class="flex items-end gap-2">
<x-forms.button type="submit">Save</x-forms.button>
<livewire:project.delete-project :disabled="!$project->isEmpty()" :project_id="$project->id" />

View File

@@ -4,7 +4,7 @@
</x-slot>
<form wire:submit='submit' class="flex flex-col">
<div class="flex items-end gap-2">
<h1>Environment: {{ data_get($environment, 'name') }}</h1>
<h1>Environment: {{ data_get_str($environment, 'name')->limit(15) }}</h1>
<x-forms.button type="submit">Save</x-forms.button>
<livewire:project.delete-environment :disabled="!$environment->isEmpty()" :environment_id="$environment->id" />
</div>

View File

@@ -29,7 +29,7 @@
<x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description>
<span x-html="application.description"></span>
<span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot>
<x-slot:logo>
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
@@ -66,7 +66,7 @@
<x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo>
<span x-show="database.logo">
<span x-html="database.logo"></span>
<span x-html="window.sanitizeHTML(database.logo)"></span>
</span>
</x-slot>
</x-resource-view>

View File

@@ -303,7 +303,7 @@
x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span>
</div>
<div class="dark:text-neutral-300 leading-relaxed max-w-none"
x-html="entry.content_html">
x-html="window.sanitizeHTML(entry.content_html)">
</div>
</div>

View File

@@ -69,7 +69,7 @@
</p>
<div class="flex flex-col pt-4" x-show="showProgress">
<h2>Progress <x-loading /></h2>
<div x-html="currentStatus"></div>
<div x-html="window.sanitizeHTML(currentStatus)"></div>
</div>
</div>
<div class="flex gap-4" x-show="!showProgress">