Skip to content

Commit db23e85

Browse files
committed
fix: improve chart rendering security
1 parent 3a6430a commit db23e85

7 files changed

Lines changed: 255 additions & 88 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* D3.js sandboxed iframe renderer.
3+
*
4+
* Loaded inside an <iframe sandbox="allow-scripts"> so chart code runs in a
5+
* null-origin context — it cannot access the parent page's cookies, localStorage,
6+
* or DOM, which eliminates the persistent-XSS risk of executing stored D3 code
7+
* in the main page context.
8+
*
9+
* Responds to postMessage commands from the parent:
10+
* { type: 'render', code, series, data }
11+
* { type: 'export-image' }
12+
*
13+
* Sends postMessage replies to the parent:
14+
* { type: 'iframe-ready' }
15+
* { type: 'export-image-result', dataUrl }
16+
*/
17+
import * as d3 from 'd3';
18+
import * as topojson from 'topojson-client';
19+
20+
/** Convert Visualizer series + data arrays to plain objects for D3. */
21+
function toD3Values( series, data ) {
22+
if ( ! Array.isArray( series ) || ! Array.isArray( data ) ) return [];
23+
return data.map( ( row ) => {
24+
const obj = {};
25+
series.forEach( ( col, i ) => {
26+
obj[ col.label ] = row[ i ];
27+
} );
28+
return obj;
29+
} );
30+
}
31+
32+
function svgToPng( svg, callback ) {
33+
const rect = svg.getBoundingClientRect();
34+
const width = parseFloat( svg.getAttribute( 'width' ) ) || rect.width || 800;
35+
const height = parseFloat( svg.getAttribute( 'height' ) ) || rect.height || 600;
36+
const clone = svg.cloneNode( true );
37+
clone.setAttribute( 'width', width );
38+
clone.setAttribute( 'height', height );
39+
const serializer = new XMLSerializer();
40+
const svgText = serializer.serializeToString( clone );
41+
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( svgText );
42+
43+
const img = new Image();
44+
img.onload = () => {
45+
const canvas = document.createElement( 'canvas' );
46+
canvas.width = width;
47+
canvas.height = height;
48+
const ctx = canvas.getContext( '2d' );
49+
ctx.fillStyle = '#ffffff';
50+
ctx.fillRect( 0, 0, width, height );
51+
ctx.drawImage( img, 0, 0 );
52+
callback( canvas.toDataURL( 'image/png' ) );
53+
};
54+
img.onerror = () => callback( null );
55+
img.src = svgDataUrl;
56+
}
57+
58+
window.addEventListener( 'message', function ( event ) {
59+
const msg = event.data;
60+
if ( ! msg || typeof msg !== 'object' ) return;
61+
62+
if ( msg.type === 'render' ) {
63+
const { code, series, data } = msg;
64+
const container = document.getElementById( 'chart' );
65+
if ( ! code || ! container ) return;
66+
67+
const values = toD3Values( series, data );
68+
try {
69+
// eslint-disable-next-line no-new-func
70+
new Function( 'd3', 'topojson', 'container', 'data', code )( d3, topojson, container, values );
71+
} catch ( err ) {
72+
container.innerHTML =
73+
'<p style="color:#c00;padding:12px">Chart render error: ' + err.message + '</p>';
74+
}
75+
}
76+
77+
if ( msg.type === 'export-image' ) {
78+
const canvas = document.querySelector( 'canvas' );
79+
if ( canvas && typeof canvas.toDataURL === 'function' ) {
80+
event.source.postMessage(
81+
{ type: 'export-image-result', dataUrl: canvas.toDataURL( 'image/png' ) },
82+
'*'
83+
);
84+
return;
85+
}
86+
const svg = document.querySelector( 'svg' );
87+
if ( svg ) {
88+
svgToPng( svg, ( dataUrl ) => {
89+
event.source.postMessage( { type: 'export-image-result', dataUrl }, '*' );
90+
} );
91+
}
92+
}
93+
} );
94+
95+
// Signal readiness to the parent so it knows when to send the render command.
96+
window.parent.postMessage( { type: 'iframe-ready' }, '*' );

classes/Visualizer/D3Renderer/src/index.js

Lines changed: 86 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,35 @@
22
* D3.js frontend renderer for Visualizer.
33
*
44
* Listens to the `visualizer:render:chart:start` event fired by render-facade.js.
5-
* For charts with library === 'd3', retrieves the stored D3 code, converts the
6-
* series/data arrays to plain objects, and executes the code via new Function.
5+
* For charts with library === 'd3', creates a sandboxed <iframe> and delegates
6+
* chart execution to it via postMessage — eliminating the persistent-XSS risk of
7+
* running stored code via new Function() in the main page context.
8+
*
9+
* The iframe uses srcdoc so no physical HTML file is needed. srcdoc iframes
10+
* always have a null origin regardless of sandbox, and sandbox="allow-scripts"
11+
* ensures the chart code cannot access the parent page's cookies, localStorage,
12+
* or DOM.
713
*/
8-
import * as d3 from 'd3';
9-
import * as topojson from 'topojson-client';
10-
11-
/** Convert Visualizer series + data arrays to plain objects for D3. */
12-
function toD3Values( series, data ) {
13-
if ( ! Array.isArray( series ) || ! Array.isArray( data ) ) return [];
14-
return data.map( ( row ) => {
15-
const obj = {};
16-
series.forEach( ( col, i ) => {
17-
obj[ col.label ] = row[ i ];
18-
} );
19-
return obj;
20-
} );
14+
15+
function ensurePngName( name ) {
16+
if ( ! name ) return 'chart.png';
17+
return name.toLowerCase().endsWith( '.png' ) ? name : `${ name }.png`;
18+
}
19+
20+
function downloadDataUrl( dataUrl, name ) {
21+
const link = document.createElement( 'a' );
22+
link.href = dataUrl;
23+
link.download = ensurePngName( name );
24+
document.body.appendChild( link );
25+
link.click();
26+
link.remove();
2127
}
2228

2329
/**
24-
* Render a single D3 chart into its container element.
30+
* Render a single D3 chart into its container element via a sandboxed iframe.
2531
*
26-
* @param {string} id - DOM element ID of the container
27-
* @param {object} chart - chart entry from visualizer.charts
32+
* @param {string} id - DOM element ID of the container
33+
* @param {object} chart - chart entry from visualizer.charts
2834
*/
2935
function renderD3Chart( id, chart ) {
3036
const container = document.getElementById( id );
@@ -37,97 +43,91 @@ function renderD3Chart( id, chart ) {
3743
return;
3844
}
3945

40-
const values = toD3Values( chart.series, chart.data );
46+
const iframeJsUrl =
47+
window.vizD3Renderer && window.vizD3Renderer.iframeJsUrl
48+
? window.vizD3Renderer.iframeJsUrl
49+
: null;
50+
51+
if ( ! iframeJsUrl ) {
52+
container.innerHTML =
53+
'<p style="color:#c00;padding:12px">D3 renderer iframe URL not configured.</p>';
54+
return;
55+
}
4156

57+
// Match the iframe dimensions to the container after layout is complete.
4258
function doRender() {
43-
try {
44-
// eslint-disable-next-line no-new-func
45-
new Function( 'd3', 'topojson', 'container', 'data', code )( d3, topojson, container, values );
46-
} catch ( err ) {
47-
container.innerHTML = '<p style="color:#c00;padding:12px">Chart render error: ' + err.message + '</p>';
59+
const width = container.offsetWidth || 800;
60+
const height = container.offsetHeight || 400;
61+
62+
// srcdoc iframes always have a null origin — no physical file needed.
63+
const srcdoc =
64+
'<!DOCTYPE html><html><head><meta charset="utf-8">' +
65+
'<style>*{margin:0;padding:0;box-sizing:border-box}body{overflow:hidden}' +
66+
'#chart{width:100%;height:100vh}</style></head>' +
67+
'<body><div id="chart"></div>' +
68+
'<script src="' + iframeJsUrl + '"><\/script></body></html>';
69+
70+
const iframe = document.createElement( 'iframe' );
71+
iframe.setAttribute( 'sandbox', 'allow-scripts' );
72+
iframe.setAttribute( 'srcdoc', srcdoc );
73+
iframe.setAttribute( 'data-viz-id', id );
74+
iframe.style.cssText =
75+
'border:0;width:' + width + 'px;height:' + height + 'px;display:block;';
76+
77+
// Once the iframe signals it is ready, send the render command.
78+
function onReady( event ) {
79+
if ( event.source !== iframe.contentWindow ) return;
80+
const msg = event.data;
81+
if ( ! msg || msg.type !== 'iframe-ready' ) return;
82+
window.removeEventListener( 'message', onReady );
83+
iframe.contentWindow.postMessage(
84+
{ type: 'render', code, series: chart.series, data: chart.data },
85+
'*'
86+
);
4887
}
88+
89+
window.addEventListener( 'message', onReady );
90+
91+
container.innerHTML = '';
92+
container.appendChild( iframe );
4993
}
5094

5195
// Double requestAnimationFrame — ensures browser has completed layout before measuring.
5296
requestAnimationFrame( () => requestAnimationFrame( doRender ) );
5397
}
5498

55-
function ensurePngName( name ) {
56-
if ( ! name ) return 'chart.png';
57-
return name.toLowerCase().endsWith( '.png' ) ? name : `${ name }.png`;
58-
}
59-
60-
function downloadDataUrl( dataUrl, name ) {
61-
const link = document.createElement( 'a' );
62-
link.href = dataUrl;
63-
link.download = ensurePngName( name );
64-
document.body.appendChild( link );
65-
link.click();
66-
link.remove();
67-
}
68-
69-
function svgToPng( svg, callback ) {
70-
const rect = svg.getBoundingClientRect();
71-
const width = parseFloat( svg.getAttribute( 'width' ) ) || rect.width || 800;
72-
const height = parseFloat( svg.getAttribute( 'height' ) ) || rect.height || 600;
73-
const clone = svg.cloneNode( true );
74-
clone.setAttribute( 'width', width );
75-
clone.setAttribute( 'height', height );
76-
const serializer = new XMLSerializer();
77-
const svgText = serializer.serializeToString( clone );
78-
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( svgText );
79-
80-
const img = new Image();
81-
img.onload = () => {
82-
const canvas = document.createElement( 'canvas' );
83-
canvas.width = width;
84-
canvas.height = height;
85-
const ctx = canvas.getContext( '2d' );
86-
ctx.fillStyle = '#ffffff';
87-
ctx.fillRect( 0, 0, width, height );
88-
ctx.drawImage( img, 0, 0 );
89-
callback( canvas.toDataURL( 'image/png' ) );
90-
};
91-
img.onerror = () => callback( null );
92-
img.src = svgDataUrl;
93-
}
94-
9599
function handleImageAction( id, name, action ) {
96100
const container = document.getElementById( id );
97101
if ( ! container ) return;
98102

99-
const canvas = container.querySelector( 'canvas' );
100-
if ( canvas && typeof canvas.toDataURL === 'function' ) {
101-
const img = canvas.toDataURL( 'image/png' );
102-
if ( action === 'print' ) {
103-
const win = window.open();
104-
win.document.write( "<br><img src='" + img + "'/>" );
105-
win.document.close();
106-
win.onload = function () { win.print(); setTimeout( win.close, 500 ); };
107-
} else {
108-
downloadDataUrl( img, name );
109-
}
110-
return;
111-
}
103+
const iframe = container.querySelector( 'iframe[data-viz-id]' );
104+
if ( ! iframe || ! iframe.contentWindow ) return;
105+
106+
function onResult( event ) {
107+
if ( event.source !== iframe.contentWindow ) return;
108+
const msg = event.data;
109+
if ( ! msg || msg.type !== 'export-image-result' ) return;
110+
window.removeEventListener( 'message', onResult );
112111

113-
const svg = container.querySelector( 'svg' );
114-
if ( ! svg ) return;
112+
const dataUrl = msg.dataUrl;
113+
if ( ! dataUrl ) return;
115114

116-
svgToPng( svg, ( img ) => {
117-
if ( ! img ) return;
118115
if ( action === 'print' ) {
119116
const win = window.open();
120-
win.document.write( "<br><img src='" + img + "'/>" );
117+
win.document.write( "<br><img src='" + dataUrl + "'/>" );
121118
win.document.close();
122119
win.onload = function () { win.print(); setTimeout( win.close, 500 ); };
123120
} else {
124-
downloadDataUrl( img, name );
121+
downloadDataUrl( dataUrl, name );
125122
}
126-
} );
123+
}
124+
125+
window.addEventListener( 'message', onResult );
126+
iframe.contentWindow.postMessage( { type: 'export-image' }, '*' );
127127
}
128128

129129
( function ( $ ) {
130-
$( 'body' ).on( 'visualizer:render:chart:start', function ( e, viz ) {
130+
$( 'body' ).on( 'visualizer:render:chart:start', function ( _e, viz ) {
131131
if ( ! viz.charts ) return;
132132

133133
// Frontend mode: a specific chart ID is provided.
@@ -148,7 +148,7 @@ function handleImageAction( id, name, action ) {
148148
} );
149149
} );
150150

151-
$( 'body' ).on( 'visualizer:action:specificchart', function ( event, v ) {
151+
$( 'body' ).on( 'visualizer:action:specificchart', function ( _event, v ) {
152152
if ( v.action !== 'image' && v.action !== 'print' ) return;
153153
handleImageAction( v.id, v?.dataObj?.name, v.action );
154154
} );
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const path = require( 'path' );
2+
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
3+
4+
const buildDir = path.resolve( __dirname, 'build' );
5+
6+
/**
7+
* Main bundle (index.js) — standard wp-scripts config with WP dependency
8+
* extraction. Runs in the parent page context.
9+
*/
10+
const mainConfig = {
11+
...defaultConfig,
12+
entry: {
13+
index: path.resolve( __dirname, 'src/index.js' ),
14+
},
15+
output: {
16+
...defaultConfig.output,
17+
path: buildDir,
18+
},
19+
};
20+
21+
/**
22+
* Iframe bundle (iframe.js) — standalone IIFE that runs inside a sandboxed
23+
* iframe. WordPress dependency extraction is disabled so that d3 and
24+
* topojson are bundled inline (the iframe has no access to wp.* globals).
25+
*/
26+
const iframeConfig = {
27+
...defaultConfig,
28+
entry: {
29+
iframe: path.resolve( __dirname, 'src/iframe.js' ),
30+
},
31+
output: {
32+
...defaultConfig.output,
33+
path: buildDir,
34+
},
35+
// Remove DependencyExtractionWebpackPlugin — iframe has no WP globals.
36+
plugins: defaultConfig.plugins.filter(
37+
( plugin ) => plugin.constructor.name !== 'DependencyExtractionWebpackPlugin'
38+
),
39+
// Bundle d3/topojson inline; do not treat anything as external.
40+
externals: {},
41+
};
42+
43+
module.exports = [ mainConfig, iframeConfig ];

classes/Visualizer/Gutenberg/Block.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ public function enqueue_gutenberg_scripts() {
138138
}
139139
if ( wp_script_is( 'visualizer-d3-renderer', 'registered' ) ) {
140140
wp_enqueue_script( 'visualizer-d3-renderer' );
141+
wp_localize_script(
142+
'visualizer-d3-renderer',
143+
'vizD3Renderer',
144+
array(
145+
'iframeJsUrl' => VISUALIZER_ABSURL . 'classes/Visualizer/D3Renderer/build/iframe.js',
146+
)
147+
);
141148
}
142149

143150
// Enqueue frontend and editor block styles

0 commit comments

Comments
 (0)