@@ -38,6 +38,44 @@ export interface ChatMessage {
3838 files ?: ChatFile [ ]
3939}
4040
41+ /**
42+ * Escapes HTML entities so untrusted strings are safe to interpolate into markup.
43+ */
44+ function escapeHtml ( value : string ) : string {
45+ return value . replace (
46+ / [ & < > " ' ] / g,
47+ ( c ) => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } ) [ c ] || c
48+ )
49+ }
50+
51+ /**
52+ * Opens an image attachment preview in a new tab via a blob URL,
53+ * escaping the user-controlled filename and data URL to prevent XSS.
54+ */
55+ function openAttachmentPreview ( name : string , dataUrl : string ) : void {
56+ const safeName = escapeHtml ( name )
57+ const safeUrl = escapeHtml ( dataUrl )
58+ const html = `
59+ <!DOCTYPE html>
60+ <html>
61+ <head>
62+ <title>${ safeName } </title>
63+ <style>
64+ body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
65+ img { max-width: 100%; max-height: 100vh; object-fit: contain; }
66+ </style>
67+ </head>
68+ <body>
69+ <img src="${ safeUrl } " alt="${ safeName } " />
70+ </body>
71+ </html>
72+ `
73+ const blob = new Blob ( [ html ] , { type : 'text/html' } )
74+ const blobUrl = URL . createObjectURL ( blob )
75+ window . open ( blobUrl , '_blank' , 'noopener,noreferrer' )
76+ setTimeout ( ( ) => URL . revokeObjectURL ( blobUrl ) , 60_000 )
77+ }
78+
4179export const ClientChatMessage = memo (
4280 function ClientChatMessage ( { message } : { message : ChatMessage } ) {
4381 const [ isCopied , setIsCopied ] = useState ( false )
@@ -103,43 +141,15 @@ export const ClientChatMessage = memo(
103141 if ( validDataUrl ?. startsWith ( 'data:' ) ) {
104142 e . preventDefault ( )
105143 e . stopPropagation ( )
106- const newWindow = window . open ( '' , '_blank' )
107- if ( newWindow ) {
108- newWindow . document . write ( `
109- <!DOCTYPE html>
110- <html>
111- <head>
112- <title>${ attachment . name } </title>
113- <style>
114- body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
115- img { max-width: 100%; max-height: 100vh; object-fit: contain; }
116- </style>
117- </head>
118- <body>
119- <img src="${ validDataUrl } " alt="${ attachment . name } " />
120- </body>
121- </html>
122- ` )
123- newWindow . document . close ( )
124- }
144+ openAttachmentPreview ( attachment . name , validDataUrl )
125145 }
126146 } }
127147 onKeyDown = { ( event ) => {
128148 const validDataUrl = attachment . dataUrl ?. trim ( )
129149 if ( ! validDataUrl ?. startsWith ( 'data:' ) ) return
130150 if ( event . key === 'Enter' || event . key === ' ' ) {
131151 event . preventDefault ( )
132- const newWindow = window . open ( '' , '_blank' )
133- if ( newWindow ) {
134- newWindow . document . write ( `
135- <html>
136- <head><title>${ attachment . name } </title></head>
137- <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111;">
138- <img src="${ validDataUrl } " alt="${ attachment . name } " style="max-width:100%;max-height:100vh;object-fit:contain;" />
139- </body>
140- </html>
141- ` )
142- }
152+ openAttachmentPreview ( attachment . name , validDataUrl )
143153 }
144154 } }
145155 >
0 commit comments