@@ -17,6 +17,13 @@ export class MessageRenderer {
1717 div . textContent = text ;
1818 return div . innerHTML ;
1919 }
20+
21+ // Escape for attribute contexts (adds quote escaping)
22+ escapeAttr ( s ) {
23+ return this . escapeHtml ( String ( s ) )
24+ . replace ( / " / g, '"' )
25+ . replace ( / ' / g, ''' ) ;
26+ }
2027
2128 // Helper method to create lucide icons
2229 createIcon ( iconName , options = { } ) {
@@ -104,11 +111,20 @@ export class MessageRenderer {
104111 'b' , 'i' , 'em' , 'strong' , 'u' , 'a' , 'p' , 'ul' , 'ol' , 'li' , 'code' ,
105112 'pre' , 'img' , 'h1' , 'h2' , 'h3' , 'h4' , 'h5' , 'h6' , 'br' , 'span' , 'div'
106113 ] ,
107- ALLOWED_ATTR : [ 'href' , 'src' , 'alt' , 'title' , 'target' , 'style' ]
114+ ALLOWED_ATTR : [ 'href' , 'src' , 'alt' , 'title' , 'target' , 'style' , 'rel' ]
108115 } ) ;
109116
110117 // 3. Convert remaining plain URLs into clickable links
111- html = html . replace ( / (?< ! [ " ' > ] ) \b h t t p s ? : \/ \/ [ ^ \s < ] + / g, '<a href="$&" target="_blank" rel="noopener noreferrer" style="color:#0066cc">$&</a>' ) ;
118+ html = html . replace ( / (?< ! [ " ' > ] ) \b h t t p s ? : \/ \/ [ ^ \s < ] + / g, ( url ) => {
119+ const safeHref = this . escapeAttr ( url ) ;
120+ const safeText = this . escapeHtml ( url ) ;
121+ return `<a href="${ safeHref } " target="_blank" rel="noopener noreferrer" style="color:#0066cc">${ safeText } </a>` ;
122+ } ) ;
123+ // Defense-in-depth: sanitize again
124+ html = DOMPurify . sanitize ( html , {
125+ ALLOWED_TAGS : [ 'b' , 'i' , 'em' , 'strong' , 'u' , 'a' , 'p' , 'ul' , 'ol' , 'li' , 'code' , 'pre' , 'img' , 'h1' , 'h2' , 'h3' , 'h4' , 'h5' , 'h6' , 'br' , 'span' , 'div' ] ,
126+ ALLOWED_ATTR : [ 'href' , 'src' , 'alt' , 'title' , 'target' , 'style' , 'rel' ]
127+ } ) ;
112128
113129 return html ;
114130 }
@@ -166,52 +182,52 @@ export class MessageRenderer {
166182 if ( message . system ) messageClass += ' system' ;
167183
168184 let messageHtml = `
169- <div class="${ messageClass } " id="${ this . escapeHtml ( messageId ) } "
170- data-user="${ this . escapeHtml ( user ) } "
171- data-time="${ this . escapeHtml ( time ) } "
172- data-message-id="${ this . escapeHtml ( messageId ) } ">
185+ <div class="${ messageClass } " id="${ this . escapeAttr ( messageId ) } "
186+ data-user="${ this . escapeAttr ( user ) } "
187+ data-time="${ this . escapeAttr ( String ( time ) ) } "
188+ data-message-id="${ this . escapeAttr ( messageId ) } ">
173189 ` ;
174190
175191 // Add reply reference if this is a reply (before timestamp and user)
176192 if ( replyInfo ) {
177193 messageHtml += `
178- <div class="reply-reference" data-message-id="${ this . escapeHtml ( replyInfo . messageId ) } ">
194+ <div class="reply-reference" data-message-id="${ this . escapeAttr ( replyInfo . messageId ) } ">
179195 ↳ Replying to ${ this . escapeHtml ( replyInfo . replyUser ) }
180196 </div>
181197 ` ;
182198 }
183199
184200 messageHtml += `
185201 <span class="time">[${ this . escapeHtml ( date ) } ]</span>
186- <span class="user${ isModerator ? ' moderator' : '' } "
187- style="color:${ this . escapeHtml ( color ) } "
188- data-user="${ this . escapeHtml ( user ) } "><${ this . escapeHtml ( user ) } ></span>
202+ <span class="user${ isModerator ? ' moderator' : '' } "
203+ style="color:${ this . escapeAttr ( color ) } "
204+ data-user="${ this . escapeAttr ( user ) } "><${ this . escapeHtml ( user ) } ></span>
189205 ` ;
190206
191207 // Add the message content
192208 if ( fileAttachment ) {
193209 if ( fileAttachment . type . startsWith ( 'image/' ) ) {
194- const imageUrl = this . escapeHtml ( fileAttachment . url || fileAttachment . data ) ;
195- const imageName = this . escapeHtml ( fileAttachment . name ) ;
210+ const imageUrl = this . escapeAttr ( fileAttachment . url || fileAttachment . data ) ;
211+ const imageName = this . escapeAttr ( fileAttachment . name ) ;
196212 const uploadedBy = this . escapeHtml ( fileAttachment . uploadedBy || 'Unknown' ) ;
197213 const uploadedAt = fileAttachment . uploadedAt ? this . escapeHtml ( new Date ( fileAttachment . uploadedAt ) . toLocaleString ( ) ) : '' ;
198214 const titleText = `Uploaded by ${ uploadedBy } ${ uploadedAt ? ' on ' + uploadedAt : '' } ` ;
199215
200216 messageHtml += `
201217 <span class="text">
202- <img src="${ imageUrl } "
203- alt="${ imageName } "
218+ <img src="${ imageUrl } "
219+ alt="${ imageName } "
204220 class="image-attachment clickable-image"
205221 data-url="${ imageUrl } "
206- title="${ this . escapeHtml ( titleText ) } ">
222+ title="${ this . escapeAttr ( titleText ) } ">
207223 </span>
208224 ` ;
209225 } else {
210226 const iconName = this . getFileIconName ( fileAttachment . type ) ;
211227 const iconHtml = this . createIcon ( iconName , {
212228 style : { width : '16px' , height : '16px' , marginRight : '4px' }
213229 } ) ;
214- const fileUrl = this . escapeHtml ( fileAttachment . url || fileAttachment . data ) ;
230+ const fileUrl = this . escapeAttr ( fileAttachment . url || fileAttachment . data ) ;
215231 const fileName = this . escapeHtml ( fileAttachment . name ) ;
216232 const uploadedBy = this . escapeHtml ( fileAttachment . uploadedBy || 'Unknown' ) ;
217233 const uploadedAt = fileAttachment . uploadedAt ? this . escapeHtml ( new Date ( fileAttachment . uploadedAt ) . toLocaleString ( ) ) : '' ;
@@ -220,12 +236,12 @@ export class MessageRenderer {
220236
221237 messageHtml += `
222238 <span class="text">
223- <a href="${ fileUrl } "
224- ${ fileAttachment . filename ? `download="${ fileName } "` : 'target="_blank" rel="noopener noreferrer"' }
239+ <a href="${ fileUrl } "
240+ ${ fileAttachment . filename ? `download="${ this . escapeAttr ( fileName ) } "` : 'target="_blank" rel="noopener noreferrer"' }
225241 class="file-attachment"
226- title="${ this . escapeHtml ( titleText ) } ">
242+ title="${ this . escapeAttr ( titleText ) } ">
227243 ${ iconHtml }
228- ${ fileName } (${ this . escapeHtml ( fileSize ) } )
244+ ${ this . escapeHtml ( fileName ) } (${ this . escapeHtml ( fileSize ) } )
229245 </a>
230246 </span>
231247 ` ;
0 commit comments