Skip to content

Commit 983370e

Browse files
committed
fix(chat): prevent XSS in attachment preview via filename/data URL escaping
Replace document.write with an escaped blob URL preview: HTML-entity encode the user-controlled filename and data URL, open with noopener,noreferrer, and revoke the blob URL after navigation.
1 parent 4076d76 commit 983370e

1 file changed

Lines changed: 40 additions & 30 deletions

File tree

apps/sim/app/chat/components/message/message.tsx

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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+
4179
export 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

Comments
 (0)