From 74080004e8156ad1f302f9c3b5ad809c6b3b8742 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:05:55 +0300 Subject: [PATCH] fix: media upload security hardening from 6-agent review - uploadCount: always decrement in finally (prevent permanent uploading state) - AbortSignal: pass to actual HTTP request (cancel network, not just UI) - Concurrent uploads: use Set instead of single ref - DOMPurify hooks: scope inside sanitizeHtml() to avoid global pollution - Video src: restrict to HTTPS only (was allowing HTTP) - GIF: remove from accept attributes (backend rejects GIF) - Ref mutation: move handleMediaUploadRef update to useEffect --- src/api/news.ts | 4 +- src/pages/AdminNewsCreate.tsx | 28 ++++--- src/pages/NewsArticle.tsx | 133 ++++++++++++++++++---------------- 3 files changed, 88 insertions(+), 77 deletions(-) diff --git a/src/api/news.ts b/src/api/news.ts index bcad9d3..996d5c0 100644 --- a/src/api/news.ts +++ b/src/api/news.ts @@ -58,13 +58,13 @@ export const newsApi = { return response.data; }, - uploadMedia: async (file: File): Promise => { + uploadMedia: async (file: File, signal?: AbortSignal): Promise => { const formData = new FormData(); formData.append('file', file); const response = await apiClient.post( '/cabinet/admin/news/media/upload', formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, + { headers: { 'Content-Type': 'multipart/form-data' }, signal }, ); return response.data; }, diff --git a/src/pages/AdminNewsCreate.tsx b/src/pages/AdminNewsCreate.tsx index e1cafdc..c5578d1 100644 --- a/src/pages/AdminNewsCreate.tsx +++ b/src/pages/AdminNewsCreate.tsx @@ -215,7 +215,7 @@ export default function AdminNewsCreate() { const isUploading = uploadCount > 0; const [isDragging, setIsDragging] = useState(false); const [isFeaturedImageUploading, setIsFeaturedImageUploading] = useState(false); - const uploadAbortRef = useRef(null); + const activeUploadsRef = useRef(new Set()); // Ref to hold the media upload handler — allows editorProps.handlePaste to // reference it without a circular dependency with useEditor. @@ -301,14 +301,13 @@ export default function AdminNewsCreate() { return; } - uploadAbortRef.current?.abort(); const controller = new AbortController(); - uploadAbortRef.current = controller; + activeUploadsRef.current.add(controller); setUploadCount((c) => c + 1); try { - const result = await newsApi.uploadMedia(file); + const result = await newsApi.uploadMedia(file, controller.signal); if (controller.signal.aborted) return; if (!isSafeUrl(result.url)) { @@ -335,19 +334,26 @@ export default function AdminNewsCreate() { haptic.error(); setSaveError(t('news.admin.uploadError')); } finally { - if (!controller.signal.aborted) setUploadCount((c) => c - 1); + activeUploadsRef.current.delete(controller); + setUploadCount((c) => c - 1); } }, [editor, haptic, t], ); // Keep the ref in sync so editorProps handlers can access the latest callback - handleMediaUploadRef.current = handleMediaUpload; - - // Cancel in-flight uploads on unmount to prevent state updates on destroyed editor useEffect(() => { + handleMediaUploadRef.current = handleMediaUpload; + }, [handleMediaUpload]); + + // Cancel all in-flight uploads on unmount to prevent state updates on destroyed editor + useEffect(() => { + const uploads = activeUploadsRef.current; return () => { - uploadAbortRef.current?.abort(); + for (const controller of uploads) { + controller.abort(); + } + uploads.clear(); }; }, []); @@ -689,7 +695,7 @@ export default function AdminNewsCreate() { { - if (node.tagName === 'IFRAME') { - const src = node.getAttribute('src') ?? ''; - if (!isAllowedIframeSrc(src)) { - node.remove(); - return; - } - // Force sandbox attribute on surviving iframes for defense-in-depth. - // allow-scripts + allow-same-origin are needed for YouTube/Vimeo embeds. - node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); - // Strip any allow/allowfullscreen values we did not explicitly set - if (node.hasAttribute('allow')) { - // Only permit safe feature policies for video embeds - node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); - } - } -}); - -// Hook: validate