mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix: register DOMPurify hooks once, abort featured upload, fix double drop
- Register DOMPurify hooks at module init (not per sanitizeHtml call) - Always set iframe allow attribute (not conditional on existing) - Add AbortController to featured image upload for unmount cleanup - Check defaultPrevented in React onDrop to prevent double upload with TipTap - Remove explicit Content-Type header on upload (axios auto-sets boundary)
This commit is contained in:
@@ -64,7 +64,7 @@ export const newsApi = {
|
||||
const response = await apiClient.post<NewsMediaUploadResponse>(
|
||||
'/cabinet/admin/news/media/upload',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, signal },
|
||||
{ signal },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -382,16 +382,21 @@ export default function AdminNewsCreate() {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeUploadsRef.current.add(controller);
|
||||
setIsFeaturedImageUploading(true);
|
||||
|
||||
try {
|
||||
const result = await newsApi.uploadMedia(file);
|
||||
const result = await newsApi.uploadMedia(file, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setFeaturedImageUrl(result.url);
|
||||
haptic.success();
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
haptic.error();
|
||||
setSaveError(t('news.admin.uploadError'));
|
||||
} finally {
|
||||
activeUploadsRef.current.delete(controller);
|
||||
setIsFeaturedImageUploading(false);
|
||||
}
|
||||
},
|
||||
@@ -410,8 +415,10 @@ export default function AdminNewsCreate() {
|
||||
|
||||
const handleEditorDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
// TipTap's handleDrop already handled media files dropped on the editor content
|
||||
if (e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleMediaUpload(file);
|
||||
},
|
||||
|
||||
@@ -170,70 +170,68 @@ const SANITIZE_CONFIG = {
|
||||
*/
|
||||
const articlePurify = DOMPurify(window);
|
||||
|
||||
// Register sanitization hooks once at module init.
|
||||
// All hooks are stateless and idempotent — no need to add/remove per call.
|
||||
|
||||
// Hook: strip iframes with disallowed src
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IFRAME') {
|
||||
const src = node.getAttribute('src') ?? '';
|
||||
if (!isAllowedIframeSrc(src)) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
// Force sandbox — allow-scripts + allow-same-origin needed for YouTube/Vimeo
|
||||
// (cross-origin, so sandbox escape via frameElement is not possible)
|
||||
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||
// Always set allow — restricts permissions even if original had none
|
||||
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||
}
|
||||
});
|
||||
|
||||
// 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://
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'VIDEO') {
|
||||
const src = node.getAttribute('src') ?? '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
node.setAttribute('controls', '');
|
||||
node.setAttribute('preload', 'metadata');
|
||||
}
|
||||
});
|
||||
|
||||
// Hook: force safe link attributes
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// Hook: restrict inline styles to text-align only (used by TipTap)
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||
if (match) {
|
||||
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||
} else {
|
||||
node.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
articlePurify.removeAllHooks();
|
||||
|
||||
// Hook: strip iframes with disallowed src
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IFRAME') {
|
||||
const src = node.getAttribute('src') ?? '';
|
||||
if (!isAllowedIframeSrc(src)) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
// Force sandbox — allow-scripts + allow-same-origin needed for YouTube/Vimeo
|
||||
// (cross-origin, so sandbox escape via frameElement is not possible)
|
||||
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||
if (node.hasAttribute('allow')) {
|
||||
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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://
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'VIDEO') {
|
||||
const src = node.getAttribute('src') ?? '';
|
||||
try {
|
||||
const url = new URL(src);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
node.setAttribute('controls', '');
|
||||
node.setAttribute('preload', 'metadata');
|
||||
}
|
||||
});
|
||||
|
||||
// Hook: force safe link attributes
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// Hook: restrict inline styles to text-align only (used by TipTap)
|
||||
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||
if (match) {
|
||||
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||
} else {
|
||||
node.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = articlePurify.sanitize(html, SANITIZE_CONFIG);
|
||||
articlePurify.removeAllHooks();
|
||||
return result;
|
||||
return articlePurify.sanitize(html, SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user