Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

### Bug Fixes

- `SandBox`: Inject the resize script into the document `<head>` so an unclosed attribute quote in the sandboxed HTML can no longer swallow the script and leak its source as visible text in the preview. ([#79920](https://github.com/WordPress/gutenberg/pull/79920))
- `Divider`: Restore lower-specificity border styles so custom border colors can override the default divider color. ([#79534](https://github.com/WordPress/gutenberg/pull/79534))
- `Button`: Fix the focus ring for buttons rendered as links ([#79837](https://github.com/WordPress/gutenberg/pull/79837)).

Expand Down
239 changes: 143 additions & 96 deletions packages/components/src/sandbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,17 @@ export const VIEWPORT_UNIT_VALUE_REGEX =
const observeAndResizeJS = function () {
const { MutationObserver } = window;

if ( ! MutationObserver || ! document.body || ! window.parent ) {
if ( ! MutationObserver || ! window.parent ) {
return;
}

function sendResize() {
// This runs in the document <head>, and as a `load`/`resize` listener,
// so the body may not exist yet. Bail rather than throw on a null body.
if ( ! document.body ) {
return;
}

const clientBoundingRect = document.body.getBoundingClientRect();

window.parent.postMessage(
Expand All @@ -54,60 +60,88 @@ const observeAndResizeJS = function () {
);
}

const observer = new MutationObserver( sendResize );
observer.observe( document.body, {
attributes: true,
attributeOldValue: false,
characterData: true,
characterDataOldValue: false,
childList: true,
subtree: true,
} );
// Runs the body-dependent setup once the document body is available. Reads
// `document.body` into a local so the null check narrows it for the rest of
// the function: this runs in <head> and via DOMContentLoaded, so the body
// isn't guaranteed to exist yet.
function connect() {
const { body } = document;
if ( ! body ) {
return;
}

window.addEventListener( 'load', sendResize, true );
const observer = new MutationObserver( sendResize );
observer.observe( body, {
attributes: true,
attributeOldValue: false,
characterData: true,
characterDataOldValue: false,
childList: true,
subtree: true,
} );

// Hack: Remove viewport unit styles, as these are relative
// the iframe root and interfere with our mechanism for
// determining the unconstrained page bounds.
function removeViewportStyles( ruleOrNode: ElementCSSInlineStyle ) {
if ( ruleOrNode.style ) {
(
[ 'width', 'height', 'minHeight', 'maxHeight' ] as const
).forEach( function ( style ) {
if (
/^\d*\.?\d+(?:vw|vh|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)$/.test(
ruleOrNode.style[ style ]
)
) {
ruleOrNode.style[ style ] = '';
}
} );
}
}

// Hack: Remove viewport unit styles, as these are relative
// the iframe root and interfere with our mechanism for
// determining the unconstrained page bounds.
function removeViewportStyles( ruleOrNode: ElementCSSInlineStyle ) {
if ( ruleOrNode.style ) {
(
[ 'width', 'height', 'minHeight', 'maxHeight' ] as const
).forEach( function ( style ) {
if (
/^\d*\.?\d+(?:vw|vh|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)$/.test(
ruleOrNode.style[ style ]
)
) {
ruleOrNode.style[ style ] = '';
Array.prototype.forEach.call(
document.querySelectorAll( '[style]' ),
removeViewportStyles
);
Array.prototype.forEach.call(
document.styleSheets,
function ( stylesheet ) {
let rules;
try {
rules = stylesheet.cssRules || stylesheet.rules;
} catch {
// Reading `cssRules` throws a SecurityError for a
// cross-origin stylesheet (e.g. a remote <link> in the
// user's HTML). Skip those rather than aborting the setup.
return;
}
} );
}
}
Array.prototype.forEach.call( rules, removeViewportStyles );
}
);

Array.prototype.forEach.call(
document.querySelectorAll( '[style]' ),
removeViewportStyles
);
Array.prototype.forEach.call(
document.styleSheets,
function ( stylesheet ) {
Array.prototype.forEach.call(
stylesheet.cssRules || stylesheet.rules,
removeViewportStyles
);
}
);
body.style.position = 'absolute';
body.style.width = '100%';
body.setAttribute( 'data-resizable-iframe-connected', '' );

document.body.style.position = 'absolute';
document.body.style.width = '100%';
document.body.setAttribute( 'data-resizable-iframe-connected', '' );
sendResize();
}

sendResize();
window.addEventListener( 'load', sendResize, true );

// Resize events can change the width of elements with 100% width, but we don't
// get an DOM mutations for that, so do the resize when the window is resized, too.
window.addEventListener( 'resize', sendResize, true );

// This script is injected into the document <head> so it parses before the
// (possibly malformed) user content in <body>: an unclosed attribute quote
// in that content can't then swallow this <script> tag and dump its source
// into the preview as visible text. Because <head> runs before <body>
// exists, defer the body-dependent setup until the DOM is ready.
if ( document.body ) {
connect();
} else {
document.addEventListener( 'DOMContentLoaded', connect );
}
Comment thread
ciampo marked this conversation as resolved.
};

// TODO: These styles shouldn't be coupled with WordPress.
Expand Down Expand Up @@ -135,23 +169,48 @@ const style = `
`;

/**
* Builds the full HTML document string for the sandbox iframe content.
* Builds the full HTML document string for the sandbox iframe content. Shared
* by both the isolated (`srcdoc`) and same-origin (`contentDocument.write`)
* sandboxes so the two paths always emit the same markup — notably keeping the
* resize script in `<head>`, ahead of the user content, on both. A single
* builder means the write path cannot drift back to putting the script in
* `<body>` while the `srcdoc` path keeps it in `<head>`.
*
* `lang` is passed in rather than read here because the two callers source it
* from different documents: the isolated sandbox uses the module's `document`,
* while the same-origin sandbox uses the iframe's `ownerDocument`. They are
* normally the same document, but keeping the argument preserves each path's
* exact prior behavior.
*
* Exported for tests.
*
* @param props
* @param props.html User-supplied HTML for the iframe body.
* @param props.title Document title.
* @param props.type Optional class name for the `<html>`/`<body>` elements.
* @param props.styles CSS rule strings, injected as `<style>` tags in the head.
* @param props.scripts External script URLs, injected as `<script src>` in the
* body (embeds expect their scripts there).
* @param props.lang Language for the `<html lang>` attribute.
* @return The full `<!DOCTYPE html>…` document string.
*/
function buildSandBoxDocument( {
export function buildSandBoxDocument( {
html,
title,
type,
styles,
scripts,
lang,
}: {
html: string;
title: string;
type?: string;
styles: string[];
scripts: string[];
lang: string;
} ): string {
const htmlDoc = (
<html lang={ document.documentElement.lang } className={ type }>
<html lang={ lang } className={ type }>
<head>
<title>{ title }</title>
<style dangerouslySetInnerHTML={ { __html: style } } />
Expand All @@ -161,18 +220,18 @@ function buildSandBoxDocument( {
dangerouslySetInnerHTML={ { __html: rules } }
/>
) ) }
</head>
<body
data-resizable-iframe-connected="data-resizable-iframe-connected"
className={ type }
>
<div dangerouslySetInnerHTML={ { __html: html } } />
<script
type="text/javascript"
dangerouslySetInnerHTML={ {
__html: `(${ observeAndResizeJS.toString() })();`,
} }
/>
</head>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This it the meat of the PR moving the resize <script> out of <body> and into <head>, in both sandbox variants.

Because a script that has already been parsed in <head> it can't be retroactively swallowed by an unclosed attribute quote appearing later in <body>.

To keep the resize behaviour working, the body-dependent work is deferred until the DOM is ready. That's all the connect() / DOMContentLoaded stuff does: preserve existing behaviour after the move.

<body
data-resizable-iframe-connected="data-resizable-iframe-connected"
className={ type }
>
<div dangerouslySetInnerHTML={ { __html: html } } />
{ scripts.map( ( src ) => (
<script key={ src } src={ src } />
) ) }
Expand Down Expand Up @@ -209,7 +268,18 @@ function IsolatedSandBox( {
const [ height, setHeight ] = useState( 0 );

const srcDoc = useMemo(
() => buildSandBoxDocument( { html, title, type, styles, scripts } ),
() =>
buildSandBoxDocument( {
html,
title,
Comment thread
ramonjd marked this conversation as resolved.
type,
styles,
scripts,
// Read inside the memo: the `no-dom-globals-in-react-fc` lint
// rule forbids DOM globals in the render body. `lang` rarely
// changes after load, so leaving it out of the deps is fine.
lang: document.documentElement.lang,
} ),
[ html, title, type, styles, scripts ]
);

Expand Down Expand Up @@ -345,48 +415,25 @@ function SameOriginSandBox( {
return;
}

// Put the html snippet into a html document, and then write it to the iframe's document
// we can use this in the future to inject custom styles or scripts.
// Scripts go into the body rather than the head, to support embedded content such as Instagram
// that expect the scripts to be part of the body.
const htmlDoc = (
<html
lang={ ownerDocument.documentElement.lang }
className={ type }
>
<head>
<title>{ title }</title>
<style dangerouslySetInnerHTML={ { __html: style } } />
{ styles.map( ( rules, i ) => (
<style
key={ i }
dangerouslySetInnerHTML={ { __html: rules } }
/>
) ) }
</head>
<body
data-resizable-iframe-connected="data-resizable-iframe-connected"
className={ type }
>
<div dangerouslySetInnerHTML={ { __html: html } } />
<script
type="text/javascript"
dangerouslySetInnerHTML={ {
__html: `(${ observeAndResizeJS.toString() })();`,
} }
/>
{ scripts.map( ( src ) => (
<script key={ src } src={ src } />
) ) }
</body>
</html>
);

// Writing the document like this makes it act in the same way as if it was
// loaded over the network, so DOM creation and mutation, script execution, etc.
// all work as expected.
// Put the html snippet into a html document, and then write it to the
// iframe's document. The external `scripts` still go into the body to
// support embedded content such as Instagram that expects them there;
// only the resize helper lives in the head.
//
// Writing the document like this makes it act in the same way as if it
// was loaded over the network, so DOM creation and mutation, script
// execution, etc. all work as expected.
contentDocument.open();
contentDocument.write( '<!DOCTYPE html>' + renderToString( htmlDoc ) );
contentDocument.write(
buildSandBoxDocument( {
html,
title,
type,
styles,
scripts,
lang: ownerDocument.documentElement.lang,
} )
);
contentDocument.close();
}

Expand Down
43 changes: 42 additions & 1 deletion packages/components/src/sandbox/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import SandBox, { VIEWPORT_UNIT_VALUE_REGEX } from '..';
import SandBox, { VIEWPORT_UNIT_VALUE_REGEX, buildSandBoxDocument } from '..';

describe( 'SandBox', () => {
const TestWrapper = () => {
Expand Down Expand Up @@ -91,6 +91,47 @@ describe( 'SandBox', () => {
);
} );

it( 'places the resize script in the head, before the user content', () => {
Comment thread
ramonjd marked this conversation as resolved.
// The resize script must parse before the (possibly malformed) user
// content in the body. Otherwise an unclosed attribute quote in that
// content swallows the <script> tag and its source leaks into the
// preview as visible text.
render( <SandBox html="<p>User content</p>" title="Head Script" /> );

const iframe = screen.getByTitle< HTMLIFrameElement >( 'Head Script' );
const srcDoc = iframe.getAttribute( 'srcdoc' ) ?? '';

const resizeScriptIndex = srcDoc.indexOf( 'MutationObserver' );
const bodyIndex = srcDoc.indexOf( '<body' );
const userContentIndex = srcDoc.indexOf( '<p>User content</p>' );

expect( resizeScriptIndex ).toBeGreaterThan( -1 );
expect( resizeScriptIndex ).toBeLessThan( bodyIndex );
expect( resizeScriptIndex ).toBeLessThan( userContentIndex );
} );

it( 'builds a document with the resize script in the head, before the body', () => {
// Both sandboxes render this document: the isolated one as `srcdoc`,
// the same-origin one via `contentDocument.write()`. Testing the shared
// builder covers the write path too, so a future change cannot move the
// resize helper back into the body on either path.
const doc = buildSandBoxDocument( {
html: '<p>User content</p>',
title: 'Doc',
styles: [],
scripts: [],
lang: 'en',
} );

const resizeScriptIndex = doc.indexOf( 'MutationObserver' );
const bodyIndex = doc.indexOf( '<body' );
const userContentIndex = doc.indexOf( '<p>User content</p>' );

expect( resizeScriptIndex ).toBeGreaterThan( -1 );
expect( resizeScriptIndex ).toBeLessThan( bodyIndex );
expect( resizeScriptIndex ).toBeLessThan( userContentIndex );
} );

it( 'should update srcdoc when html prop changes', () => {
render( <TestWrapper /> );

Expand Down
Loading