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) { popToast(custom) {
let html = ''; let html = '';
if (typeof custom != 'undefined') { if (typeof custom != 'undefined') {
html = custom; html = window.sanitizeHTML(custom);
} }
toast(this.title, { description: this.description, type: this.type, position: this.position, html: html }) toast(this.title, { description: this.description, type: this.type, position: this.position, html: html })
} }
@@ -276,13 +276,16 @@
if(event.detail.position){ if(event.detail.position){
position = 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({ toasts.unshift({
id: 'toast-' + Math.random().toString(16).slice(2), id: 'toast-' + Math.random().toString(16).slice(2),
show: false, show: false,
message: event.detail.message, message: event.detail.message,
description: event.detail.description, description: event.detail.description,
type: event.detail.type, type: event.detail.type,
html: event.detail.html html: sanitizedHtml
}); });
" "
@mouseenter="toastsHovered=true;" @mouseleave="toastsHovered=false" x-init="if (layout == 'expanded') { @mouseenter="toastsHovered=true;" @mouseleave="toastsHovered=false" x-init="if (layout == 'expanded') {
@@ -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" 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> fill="currentColor"></path>
</svg> </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> </p>
</div> </div>
<div x-show="toast.description" :class="{ 'pl-5': toast.type!='default' }" <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" 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> </div>
</template> </template>
<template x-if="toast.html"> <template x-if="toast.html">
<div x-html="toast.html"></div> <div x-html="window.sanitizeHTML(toast.html)"></div>
</template> </template>
<span class="absolute mt-1 text-xs right-[4.4rem] text-success font-bold" <span class="absolute mt-1 text-xs right-[4.4rem] text-success font-bold"
x-show="copyNotification" x-show="copyNotification"

View File

@@ -35,9 +35,9 @@
@endphp @endphp
<title>{{ $name }}{{ $title ?? 'Coolify' }}</title> <title>{{ $name }}{{ $title ?? 'Coolify' }}</title>
@env('local') @env('local')
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" /> <link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" />
@else @else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" /> <link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" />
@endenv @endenv
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/js/app.js', 'resources/css/app.css']) @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/echo.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/pusher.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/apexcharts.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/purify.min.js') }}"></script>
@endauth @endauth
</head> </head>
@section('body') @section('body')
@@ -61,6 +62,32 @@
<body> <body>
<x-toast /> <x-toast />
<script data-navigate-once> <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)) { if (!('theme' in localStorage)) {
localStorage.theme = 'dark'; localStorage.theme = 'dark';
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')

View File

@@ -4,7 +4,7 @@
</x-slot> </x-slot>
<form wire:submit='submit' class="flex flex-col pb-10"> <form wire:submit='submit' class="flex flex-col pb-10">
<div class="flex gap-2"> <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"> <div class="flex items-end gap-2">
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
<livewire:project.delete-project :disabled="!$project->isEmpty()" :project_id="$project->id" /> <livewire:project.delete-project :disabled="!$project->isEmpty()" :project_id="$project->id" />

View File

@@ -4,7 +4,7 @@
</x-slot> </x-slot>
<form wire:submit='submit' class="flex flex-col"> <form wire:submit='submit' class="flex flex-col">
<div class="flex items-end gap-2"> <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> <x-forms.button type="submit">Save</x-forms.button>
<livewire:project.delete-environment :disabled="!$environment->isEmpty()" :environment_id="$environment->id" /> <livewire:project.delete-environment :disabled="!$environment->isEmpty()" :environment_id="$environment->id" />
</div> </div>

View File

@@ -29,7 +29,7 @@
<x-resource-view> <x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot> <x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description> <x-slot:description>
<span x-html="application.description"></span> <span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot> </x-slot>
<x-slot:logo> <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" <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:description><span x-text="database.description"></span></x-slot>
<x-slot:logo> <x-slot:logo>
<span x-show="database.logo"> <span x-show="database.logo">
<span x-html="database.logo"></span> <span x-html="window.sanitizeHTML(database.logo)"></span>
</span> </span>
</x-slot> </x-slot>
</x-resource-view> </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> x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span>
</div> </div>
<div class="dark:text-neutral-300 leading-relaxed max-w-none" <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>
</div> </div>

View File

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