fix: isolated DOMPurify instance and correct video controls attribute

- Use DOMPurify(window) to avoid global hook pollution
- Fix controls default from boolean to empty string (HTML attribute semantics)
- Remove redundant non-null assertion on featured_image_url
This commit is contained in:
Fringg
2026-03-23 12:32:04 +03:00
parent 25f3602aea
commit f788f1034c
2 changed files with 15 additions and 14 deletions

View File

@@ -14,7 +14,7 @@ export const VideoExtension = Node.create({
addAttributes() { addAttributes() {
return { return {
src: { default: null }, src: { default: null },
controls: { default: true }, controls: { default: '' },
class: { default: null }, class: { default: null },
preload: { default: 'metadata' }, preload: { default: 'metadata' },
}; };
@@ -25,6 +25,6 @@ export const VideoExtension = Node.create({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(HTMLAttributes, { controls: '' })]; return ['video', mergeAttributes(HTMLAttributes)];
}, },
}); });

View File

@@ -164,16 +164,17 @@ const SANITIZE_CONFIG = {
}; };
/** /**
* Sanitize HTML with scoped DOMPurify hooks. * Isolated DOMPurify instance for article content sanitization.
* * Using a separate instance avoids polluting the global DOMPurify singleton
* Hooks are registered/removed inside this function to avoid polluting the * that other pages may use with their own hooks/config.
* global DOMPurify instance (other pages use DOMPurify with their own config).
*/ */
const articlePurify = DOMPurify(window);
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
DOMPurify.removeAllHooks(); articlePurify.removeAllHooks();
// Hook: strip iframes with disallowed src // Hook: strip iframes with disallowed src
DOMPurify.addHook('afterSanitizeAttributes', (node) => { articlePurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'IFRAME') { if (node.tagName === 'IFRAME') {
const src = node.getAttribute('src') ?? ''; const src = node.getAttribute('src') ?? '';
if (!isAllowedIframeSrc(src)) { if (!isAllowedIframeSrc(src)) {
@@ -191,7 +192,7 @@ function sanitizeHtml(html: string): string {
// Hook: validate <video> src — only allow http/https (block javascript:, data:, etc.) // Hook: validate <video> src — only allow http/https (block javascript:, data:, etc.)
// HTTP is permitted because request.base_url behind a reverse proxy returns http:// // HTTP is permitted because request.base_url behind a reverse proxy returns http://
DOMPurify.addHook('afterSanitizeAttributes', (node) => { articlePurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'VIDEO') { if (node.tagName === 'VIDEO') {
const src = node.getAttribute('src') ?? ''; const src = node.getAttribute('src') ?? '';
try { try {
@@ -210,7 +211,7 @@ function sanitizeHtml(html: string): string {
}); });
// Hook: force safe link attributes // Hook: force safe link attributes
DOMPurify.addHook('afterSanitizeAttributes', (node) => { articlePurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') { if (node.tagName === 'A') {
node.setAttribute('target', '_blank'); node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer'); node.setAttribute('rel', 'noopener noreferrer');
@@ -218,7 +219,7 @@ function sanitizeHtml(html: string): string {
}); });
// Hook: restrict inline styles to text-align only (used by TipTap) // Hook: restrict inline styles to text-align only (used by TipTap)
DOMPurify.addHook('afterSanitizeAttributes', (node) => { articlePurify.addHook('afterSanitizeAttributes', (node) => {
if (node.hasAttribute('style')) { if (node.hasAttribute('style')) {
const style = node.getAttribute('style') ?? ''; const style = node.getAttribute('style') ?? '';
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i); const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
@@ -230,8 +231,8 @@ function sanitizeHtml(html: string): string {
} }
}); });
const result = DOMPurify.sanitize(html, SANITIZE_CONFIG); const result = articlePurify.sanitize(html, SANITIZE_CONFIG);
DOMPurify.removeAllHooks(); articlePurify.removeAllHooks();
return result; return result;
} }
@@ -422,7 +423,7 @@ export default function NewsArticlePage() {
className="overflow-hidden rounded-xl" className="overflow-hidden rounded-xl"
> >
<img <img
src={article.featured_image_url!} src={article.featured_image_url}
alt={article.title} alt={article.title}
className="h-auto w-full rounded-xl object-cover" className="h-auto w-full rounded-xl object-cover"
loading="eager" loading="eager"