Skip to content

Commit ee1276c

Browse files
geeknikssl
authored andcommitted
fix(dom render): sandbox captured DOM in iframe; CSP meta is bypassable
The previous mitigation appended a CSP <meta> as the last child of <head>, so any attacker <script> inside <head> was parsed before the policy took effect. Because the blob: document inherits the dashboard origin, that script ran with same-origin access to the operator's session (e.g. via window.opener/parent), turning unauthenticated stored input from /callback into operator account takeover when "Render HTML DOM" was clicked. Render the captured DOM inside an <iframe sandbox srcdoc="..."> instead. Without allow-scripts, scripts are disabled at the parser level; without allow-same-origin, the iframe receives an opaque origin. Protection no longer depends on CSP ordering. The outer wrapper is fully under our control and contains no attacker-controlled markup outside the HTML-attribute-escaped srcdoc value.
1 parent 0c232c0 commit ee1276c

1 file changed

Lines changed: 29 additions & 24 deletions

File tree

assets/js/ezxss4.js

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -646,33 +646,38 @@ const EzXSS = {
646646
handleDOMRender() {
647647
try {
648648
const domContent = $('#dom').val();
649-
const parser = new DOMParser();
650-
const doc = parser.parseFromString(domContent, 'text/html');
651-
const meta = doc.createElement('meta');
652-
meta.httpEquiv = 'Content-Security-Policy';
653-
meta.content = "default-src 'none'; script-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline';";
654-
doc.head.appendChild(meta);
655-
656-
const serializer = new XMLSerializer();
657-
const safeContent = serializer.serializeToString(doc);
658-
const byteCharacters = unescape(encodeURIComponent(safeContent));
659-
const byteArrays = [];
660-
661-
for (let offset = 0; offset < byteCharacters.length; offset += 1024) {
662-
const slice = byteCharacters.slice(offset, offset + 1024);
663-
const byteNumbers = new Array(slice.length);
664-
665-
for (let i = 0; i < slice.length; i++) {
666-
byteNumbers[i] = slice.charCodeAt(i);
667-
}
668-
669-
byteArrays.push(new Uint8Array(byteNumbers));
670-
}
671649

672-
const blob = new Blob(byteArrays, { type: 'text/html' });
650+
// Captured DOM is attacker-controlled. Render it inside a sandboxed
651+
// iframe (no allow-scripts, no allow-same-origin) so scripts cannot
652+
// execute and same-origin DOM access is blocked at the parser level,
653+
// independent of any CSP. The outer wrapper is fully under our
654+
// control; the only attacker value is placed inside the iframe's
655+
// srcdoc attribute as HTML-attribute-escaped text.
656+
const escaped = domContent
657+
.replace(/&/g, '&amp;')
658+
.replace(/"/g, '&quot;')
659+
.replace(/</g, '&lt;')
660+
.replace(/>/g, '&gt;');
661+
662+
const wrapper = '<!DOCTYPE html>'
663+
+ '<html lang="en"><head><meta charset="utf-8">'
664+
+ '<title>Rendered DOM (sandboxed)</title>'
665+
+ '<style>html,body{margin:0;height:100%;background:#222;color:#ccc;'
666+
+ 'font:13px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}'
667+
+ '.bar{padding:6px 10px;background:#111;border-bottom:1px solid #333}'
668+
+ 'iframe{border:0;width:100%;height:calc(100vh - 30px);'
669+
+ 'display:block;background:#fff}</style>'
670+
+ '</head><body>'
671+
+ '<div class="bar">Rendered in a sandboxed iframe '
672+
+ '(scripts disabled, opaque origin).</div>'
673+
+ '<iframe sandbox srcdoc="' + escaped + '"></iframe>'
674+
+ '</body></html>';
675+
676+
const blob = new Blob([wrapper], { type: 'text/html' });
673677
const blobUrl = URL.createObjectURL(blob);
674678

675-
window.open(blobUrl, '_blank');
679+
window.open(blobUrl, '_blank', 'noopener,noreferrer');
680+
setTimeout(function () { URL.revokeObjectURL(blobUrl); }, 60000);
676681
} catch (error) {
677682
console.error('Failed to render DOM:', error);
678683
alert('Failed to render DOM content');

0 commit comments

Comments
 (0)