Move DOMPurify hooks to chats module
| @@ -263,7 +263,7 @@ import { | |||
| 263 | loadTabbyModels, | 263 | loadTabbyModels, |
| 264 | loadGenericModels, | 264 | loadGenericModels, |
| 265 | } from './scripts/textgen-models.js'; | 265 | } from './scripts/textgen-models.js'; |
| 266 | import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } from './scripts/chats.js'; | 266 | import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities, addDOMPurifyHooks } from './scripts/chats.js'; |
| 267 | import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; | 267 | import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; |
| 268 | import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; | 268 | import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; |
| 269 | import { currentUser, setUserControls } from './scripts/user.js'; | 269 | import { currentUser, setUserControls } from './scripts/user.js'; |
| @@ -363,130 +363,6 @@ toastr.options = { | |||
| 363 | }, | 363 | }, |
| 364 | }; | 364 | }; |
| 365 | 365 | ||
| 366 | // Allow target="_blank" in links | ||
| 367 | DOMPurify.addHook('afterSanitizeAttributes', function (node) { | ||
| 368 | if ('target' in node) { | ||
| 369 | node.setAttribute('target', '_blank'); | ||
| 370 | node.setAttribute('rel', 'noopener'); | ||
| 371 | } | ||
| 372 | }); | ||
| 373 | |||
| 374 | DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => { | ||
| 375 | if (!config['MESSAGE_SANITIZE']) { | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | |||
| 379 | /* Retain the classes on UI elements of messages that interact with the main UI */ | ||
| 380 | const permittedNodeTypes = ['BUTTON', 'DIV']; | ||
| 381 | if (config['MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) { | ||
| 382 | return; | ||
| 383 | } | ||
| 384 | |||
| 385 | switch (data.attrName) { | ||
| 386 | case 'class': { | ||
| 387 | if (data.attrValue) { | ||
| 388 | data.attrValue = data.attrValue.split(' ').map((v) => { | ||
| 389 | if (v.startsWith('fa-') || v.startsWith('note-') || v === 'monospace') { | ||
| 390 | return v; | ||
| 391 | } | ||
| 392 | |||
| 393 | return 'custom-' + v; | ||
| 394 | }).join(' '); | ||
| 395 | } | ||
| 396 | break; | ||
| 397 | } | ||
| 398 | } | ||
| 399 | }); | ||
| 400 | |||
| 401 | DOMPurify.addHook('uponSanitizeElement', (node, _, config) => { | ||
| 402 | if (!config['MESSAGE_SANITIZE']) { | ||
| 403 | return; | ||
| 404 | } | ||
| 405 | |||
| 406 | // Replace line breaks with <br> in unknown elements | ||
| 407 | if (node instanceof HTMLUnknownElement) { | ||
| 408 | node.innerHTML = node.innerHTML.trim().replaceAll('\n', '<br>'); | ||
| 409 | } | ||
| 410 | |||
| 411 | const isMediaAllowed = isExternalMediaAllowed(); | ||
| 412 | if (isMediaAllowed) { | ||
| 413 | return; | ||
| 414 | } | ||
| 415 | |||
| 416 | if (!(node instanceof Element)) { | ||
| 417 | return; | ||
| 418 | } | ||
| 419 | |||
| 420 | let mediaBlocked = false; | ||
| 421 | |||
| 422 | switch (node.tagName) { | ||
| 423 | case 'AUDIO': | ||
| 424 | case 'VIDEO': | ||
| 425 | case 'SOURCE': | ||
| 426 | case 'TRACK': | ||
| 427 | case 'EMBED': | ||
| 428 | case 'OBJECT': | ||
| 429 | case 'IMG': { | ||
| 430 | const isExternalUrl = (url) => (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin); | ||
| 431 | const src = node.getAttribute('src'); | ||
| 432 | const data = node.getAttribute('data'); | ||
| 433 | const srcset = node.getAttribute('srcset'); | ||
| 434 | |||
| 435 | if (srcset) { | ||
| 436 | const srcsetUrls = srcset.split(','); | ||
| 437 | |||
| 438 | for (const srcsetUrl of srcsetUrls) { | ||
| 439 | const [url] = srcsetUrl.trim().split(' '); | ||
| 440 | |||
| 441 | if (isExternalUrl(url)) { | ||
| 442 | console.warn('External media blocked', url); | ||
| 443 | node.remove(); | ||
| 444 | mediaBlocked = true; | ||
| 445 | break; | ||
| 446 | } | ||
| 447 | } | ||
| 448 | } | ||
| 449 | |||
| 450 | if (src && isExternalUrl(src)) { | ||
| 451 | console.warn('External media blocked', src); | ||
| 452 | mediaBlocked = true; | ||
| 453 | node.remove(); | ||
| 454 | } | ||
| 455 | |||
| 456 | if (data && isExternalUrl(data)) { | ||
| 457 | console.warn('External media blocked', data); | ||
| 458 | mediaBlocked = true; | ||
| 459 | node.remove(); | ||
| 460 | } | ||
| 461 | |||
| 462 | if (mediaBlocked && (node instanceof HTMLMediaElement)) { | ||
| 463 | node.autoplay = false; | ||
| 464 | node.pause(); | ||
| 465 | } | ||
| 466 | } | ||
| 467 | break; | ||
| 468 | } | ||
| 469 | |||
| 470 | if (mediaBlocked) { | ||
| 471 | const entityId = getCurrentEntityId(); | ||
| 472 | const warningShownKey = `mediaWarningShown:${entityId}`; | ||
| 473 | |||
| 474 | if (accountStorage.getItem(warningShownKey) === null) { | ||
| 475 | const warningToast = toastr.warning( | ||
| 476 | t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`, | ||
| 477 | t`External media has been blocked`, | ||
| 478 | { | ||
| 479 | timeOut: 0, | ||
| 480 | preventDuplicates: true, | ||
| 481 | onclick: () => toastr.clear(warningToast), | ||
| 482 | }, | ||
| 483 | ); | ||
| 484 | |||
| 485 | accountStorage.setItem(warningShownKey, 'true'); | ||
| 486 | } | ||
| 487 | } | ||
| 488 | }); | ||
| 489 | |||
| 490 | // Event source init | 366 | // Event source init |
| 491 | //MARK: event_types | 367 | //MARK: event_types |
| 492 | export const event_types = { | 368 | export const event_types = { |
| @@ -993,6 +869,7 @@ async function firstLoadInit() { | |||
| 993 | initStandaloneMode(); | 869 | initStandaloneMode(); |
| 994 | initLibraryShims(); | 870 | initLibraryShims(); |
| 995 | addShowdownPatch(showdown); | 871 | addShowdownPatch(showdown); |
| 872 | addDOMPurifyHooks(); | ||
| 996 | reloadMarkdownProcessor(); | 873 | reloadMarkdownProcessor(); |
| 997 | applyBrowserFixes(); | 874 | applyBrowserFixes(); |
| 998 | await getClientVersion(); | 875 | await getClientVersion(); |
| @@ -1716,6 +1716,132 @@ export function registerFileConverter(mimeType, converter) { | |||
| 1716 | converters[mimeType] = converter; | 1716 | converters[mimeType] = converter; |
| 1717 | } | 1717 | } |
| 1718 | 1718 | ||
| 1719 | export function addDOMPurifyHooks() { | ||
| 1720 | // Allow target="_blank" in links | ||
| 1721 | DOMPurify.addHook('afterSanitizeAttributes', function (node) { | ||
| 1722 | if ('target' in node) { | ||
| 1723 | node.setAttribute('target', '_blank'); | ||
| 1724 | node.setAttribute('rel', 'noopener'); | ||
| 1725 | } | ||
| 1726 | }); | ||
| 1727 | |||
| 1728 | DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => { | ||
| 1729 | if (!config['MESSAGE_SANITIZE']) { | ||
| 1730 | return; | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | /* Retain the classes on UI elements of messages that interact with the main UI */ | ||
| 1734 | const permittedNodeTypes = ['BUTTON', 'DIV']; | ||
| 1735 | if (config['MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) { | ||
| 1736 | return; | ||
| 1737 | } | ||
| 1738 | |||
| 1739 | switch (data.attrName) { | ||
| 1740 | case 'class': { | ||
| 1741 | if (data.attrValue) { | ||
| 1742 | data.attrValue = data.attrValue.split(' ').map((v) => { | ||
| 1743 | if (v.startsWith('fa-') || v.startsWith('note-') || v === 'monospace') { | ||
| 1744 | return v; | ||
| 1745 | } | ||
| 1746 | |||
| 1747 | return 'custom-' + v; | ||
| 1748 | }).join(' '); | ||
| 1749 | } | ||
| 1750 | break; | ||
| 1751 | } | ||
| 1752 | } | ||
| 1753 | }); | ||
| 1754 | |||
| 1755 | DOMPurify.addHook('uponSanitizeElement', (node, _, config) => { | ||
| 1756 | if (!config['MESSAGE_SANITIZE']) { | ||
| 1757 | return; | ||
| 1758 | } | ||
| 1759 | |||
| 1760 | // Replace line breaks with <br> in unknown elements | ||
| 1761 | if (node instanceof HTMLUnknownElement) { | ||
| 1762 | node.innerHTML = node.innerHTML.trim().replaceAll('\n', '<br>'); | ||
| 1763 | } | ||
| 1764 | |||
| 1765 | const isMediaAllowed = isExternalMediaAllowed(); | ||
| 1766 | if (isMediaAllowed) { | ||
| 1767 | return; | ||
| 1768 | } | ||
| 1769 | |||
| 1770 | if (!(node instanceof Element)) { | ||
| 1771 | return; | ||
| 1772 | } | ||
| 1773 | |||
| 1774 | let mediaBlocked = false; | ||
| 1775 | |||
| 1776 | switch (node.tagName) { | ||
| 1777 | case 'AUDIO': | ||
| 1778 | case 'VIDEO': | ||
| 1779 | case 'SOURCE': | ||
| 1780 | case 'TRACK': | ||
| 1781 | case 'EMBED': | ||
| 1782 | case 'OBJECT': | ||
| 1783 | case 'IMG': { | ||
| 1784 | const isExternalUrl = (url) => (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin); | ||
| 1785 | const src = node.getAttribute('src'); | ||
| 1786 | const data = node.getAttribute('data'); | ||
| 1787 | const srcset = node.getAttribute('srcset'); | ||
| 1788 | |||
| 1789 | if (srcset) { | ||
| 1790 | const srcsetUrls = srcset.split(','); | ||
| 1791 | |||
| 1792 | for (const srcsetUrl of srcsetUrls) { | ||
| 1793 | const [url] = srcsetUrl.trim().split(' '); | ||
| 1794 | |||
| 1795 | if (isExternalUrl(url)) { | ||
| 1796 | console.warn('External media blocked', url); | ||
| 1797 | node.remove(); | ||
| 1798 | mediaBlocked = true; | ||
| 1799 | break; | ||
| 1800 | } | ||
| 1801 | } | ||
| 1802 | } | ||
| 1803 | |||
| 1804 | if (src && isExternalUrl(src)) { | ||
| 1805 | console.warn('External media blocked', src); | ||
| 1806 | mediaBlocked = true; | ||
| 1807 | node.remove(); | ||
| 1808 | } | ||
| 1809 | |||
| 1810 | if (data && isExternalUrl(data)) { | ||
| 1811 | console.warn('External media blocked', data); | ||
| 1812 | mediaBlocked = true; | ||
| 1813 | node.remove(); | ||
| 1814 | } | ||
| 1815 | |||
| 1816 | if (mediaBlocked && (node instanceof HTMLMediaElement)) { | ||
| 1817 | node.autoplay = false; | ||
| 1818 | node.pause(); | ||
| 1819 | } | ||
| 1820 | } | ||
| 1821 | break; | ||
| 1822 | } | ||
| 1823 | |||
| 1824 | if (mediaBlocked) { | ||
| 1825 | const entityId = getCurrentEntityId(); | ||
| 1826 | const warningShownKey = `mediaWarningShown:${entityId}`; | ||
| 1827 | |||
| 1828 | if (accountStorage.getItem(warningShownKey) === null) { | ||
| 1829 | const warningToast = toastr.warning( | ||
| 1830 | t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`, | ||
| 1831 | t`External media has been blocked`, | ||
| 1832 | { | ||
| 1833 | timeOut: 0, | ||
| 1834 | preventDuplicates: true, | ||
| 1835 | onclick: () => toastr.clear(warningToast), | ||
| 1836 | }, | ||
| 1837 | ); | ||
| 1838 | |||
| 1839 | accountStorage.setItem(warningShownKey, 'true'); | ||
| 1840 | } | ||
| 1841 | } | ||
| 1842 | }); | ||
| 1843 | } | ||
| 1844 | |||
| 1719 | export function initChatUtilities() { | 1845 | export function initChatUtilities() { |
| 1720 | $(document).on('click', '.mes_hide', async function () { | 1846 | $(document).on('click', '.mes_hide', async function () { |
| 1721 | const messageBlock = $(this).closest('.mes'); | 1847 | const messageBlock = $(this).closest('.mes'); |