Skip to content

Commit 16980e7

Browse files
authored
Merge pull request #1029 from SonicJs-Org/fix/dynamic-field-media-value-xss
fix(dynamic-field): escape media field value (stored XSS)
2 parents a3c51a6 + 5162de4 commit 16980e7

1 file changed

Lines changed: 24 additions & 5 deletions

File tree

packages/core/src/templates/components/dynamic-field.template.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -891,14 +891,14 @@ export function renderDynamicField(field: FieldDefinition, options: FieldRenderO
891891
const renderMediaPreview = (url: unknown, alt: string, classes: string) => {
892892
if (typeof url !== 'string' || url === '') return ''
893893
if (isVideoUrl(url)) {
894-
return `<video src="${url}" class="${classes}" muted></video>`
894+
return `<video src="${escapeHtml(url)}" class="${classes}" muted></video>`
895895
}
896-
return `<img src="${url}" alt="${alt}" class="${classes}">`
896+
return `<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" class="${classes}">`
897897
}
898898

899899
fieldHTML = `
900900
<div class="media-field-container">
901-
<input type="hidden" id="${fieldId}" name="${fieldName}" value="${isMultiple ? mediaValues.join(',') : singleValue}" data-multiple="${isMultiple}">
901+
<input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml(String(isMultiple ? mediaValues.join(',') : singleValue))}" data-multiple="${isMultiple}">
902902
903903
${
904904
isMultiple
@@ -907,11 +907,11 @@ export function renderDynamicField(field: FieldDefinition, options: FieldRenderO
907907
${mediaValues
908908
.map(
909909
(url: string, idx: number) => `
910-
<div class="relative media-preview-item" data-url="${url}">
910+
<div class="relative media-preview-item" data-url="${escapeHtml(url)}">
911911
${renderMediaPreview(url, `Media ${idx + 1}`, 'w-full h-24 object-cover rounded-lg border border-white/20')}
912912
<button
913913
type="button"
914-
onclick="removeMediaFromMultiple('${fieldId}', '${url}')"
914+
onclick="removeMediaFromMultiple('${fieldId}', '${escapeJsAttr(url)}')"
915915
data-media-remove="true"
916916
class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 hover:bg-red-700"
917917
${disabled ? 'disabled' : ''}
@@ -2648,3 +2648,22 @@ function escapeHtml(text: string): string {
26482648
"'": '&#39;'
26492649
}[char] || char))
26502650
}
2651+
2652+
/**
2653+
* Escape a string for a single-quoted JS string inside an HTML attribute —
2654+
* e.g. onclick="f('${escapeJsAttr(x)}')". escapeHtml is insufficient: the browser
2655+
* HTML-decodes the attribute BEFORE the inline script parses, so &#39; decodes back
2656+
* to ' and terminates the string. Emits \xHH escapes so no raw '"<>&\ survives.
2657+
*/
2658+
function escapeJsAttr(value: unknown): string {
2659+
return String(value ?? '').replace(/[\\'"<>&\r\n]/g, (ch) => ({
2660+
'\\': '\\\\',
2661+
"'": '\\x27',
2662+
'"': '\\x22',
2663+
'<': '\\x3C',
2664+
'>': '\\x3E',
2665+
'&': '\\x26',
2666+
'\r': '\\r',
2667+
'\n': '\\n'
2668+
}[ch] || ch))
2669+
}

0 commit comments

Comments
 (0)