From a65909d22ca4b71c64f66c4aa6c59f3ed692203c Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Wed, 28 Jun 2017 11:55:42 +0100 Subject: [PATCH] Replace injected html with sandboxing iframe (#1392) Test with the following content: Short tweet: https://twitter.com/notnownikki/status/876229494465581056 Tall tweet: https://twitter.com/PattyJenks/status/874034832430424065 Youtube: https://www.youtube.com/watch?v=PfKUdmTq2MI Photo: https://cloudup.com/cl3Oq5MU8Rm Long tumblr post: http://doctorwho.tumblr.com/post/162052108791 --- blocks/library/embed/index.js | 10 +- components/index.js | 3 +- components/resizable-iframe/index.js | 173 ++++++++++++++++++++ components/{html-embed => sandbox}/index.js | 25 +-- 4 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 components/resizable-iframe/index.js rename components/{html-embed => sandbox}/index.js (50%) diff --git a/blocks/library/embed/index.js b/blocks/library/embed/index.js index e3280c5d05a42d..90e607301a0e4c 100644 --- a/blocks/library/embed/index.js +++ b/blocks/library/embed/index.js @@ -9,7 +9,7 @@ import { includes } from 'lodash'; */ import { __, sprintf } from 'i18n'; import { Component } from 'element'; -import { Button, Placeholder, HtmlEmbed, Spinner } from 'components'; +import { Button, Placeholder, Spinner, SandBox } from 'components'; /** * Internal dependencies @@ -22,6 +22,7 @@ import BlockAlignmentToolbar from '../../block-alignment-toolbar'; const { attr, children } = query; +// These embeds do not work in sandboxes const HOSTS_NO_PREVIEWS = [ 'facebook.com' ]; function getEmbedBlockSettings( { title, icon, category = 'embed' } ) { @@ -72,7 +73,9 @@ function getEmbedBlockSettings( { title, icon, category = 'embed' } ) { } getPhotoHtml( photo ) { - const photoPreview =

{

; + // 100% width for the preview so it fits nicely into the document, some "thumbnails" are + // acually the full size photo. + const photoPreview =

{

; return wp.element.renderToString( photoPreview ); } @@ -158,6 +161,7 @@ function getEmbedBlockSettings( { title, icon, category = 'embed' } ) { const parsedUrl = parse( url ); const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) ); + const iframeTitle = 'Embedded content from ' + parsedUrl.host; let typeClassName = 'blocks-embed'; if ( 'video' === type ) { @@ -173,7 +177,7 @@ function getEmbedBlockSettings( { title, icon, category = 'embed' } ) {

{ __( 'Previews for this are unavailable in the editor, sorry!' ) }

) : ( - + ) } { ( caption && caption.length > 0 ) || !! focus ? ( {}, + onResize: () => {}, + title: '', + }; + } + + componentDidMount() { + window.addEventListener( 'message', this.checkMessageForResize, false ); + this.maybeConnect(); + } + + componentDidUpdate() { + this.maybeConnect(); + } + + componentWillUnmount() { + window.removeEventListener( 'message', this.checkMessageForResize ); + } + + getFrameBody() { + return this.iframe.contentDocument.body; + } + + maybeConnect() { + if ( ! this.isFrameAccessible() ) { + return; + } + + const body = this.getFrameBody(); + if ( null !== body.getAttribute( 'data-resizable-iframe-connected' ) ) { + return; + } + + const script = document.createElement( 'script' ); + script.innerHTML = ` + ( function() { + var observer; + + if ( ! window.MutationObserver || ! document.body || ! window.top ) { + return; + } + + function sendResize() { + window.top.postMessage( { + action: 'resize', + width: document.body.offsetWidth, + height: document.body.offsetHeight + }, '*' ); + } + + observer = new MutationObserver( sendResize ); + observer.observe( document.body, { + attributes: true, + attributeOldValue: false, + characterData: true, + characterDataOldValue: false, + childList: true, + subtree: true + } ); + + window.addEventListener( 'load', sendResize, 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 ) { + [ 'width', 'height', 'minHeight', 'maxHeight' ].forEach( function( style ) { + if ( /^\\d+(vmin|vmax|vh|vw)$/.test( ruleOrNode.style[ style ] ) ) { + ruleOrNode.style[ style ] = ''; + } + } ); + } + + 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 ); + } ); + + document.body.style.position = 'absolute'; + document.body.setAttribute( 'data-resizable-iframe-connected', '' ); + + // Make sure that we don't miss very quick loading documents here that the observer + // doesn't see load, but haven't completely loaded when we call sendResize for the + // first time. + setTimeout( sendResize, 1000 ); + } )(); + `; + body.appendChild( script ); + } + + isFrameAccessible() { + try { + return !! this.getFrameBody(); + } catch ( e ) { + return false; + } + } + + checkMessageForResize( event ) { + const iframe = this.iframe; + + // Attempt to parse the message data as JSON if passed as string + let data = event.data || {}; + if ( 'string' === typeof data ) { + try { + data = JSON.parse( data ); + } catch ( e ) {} // eslint-disable-line no-empty + } + + // Verify that the mounted element is the source of the message + if ( ! iframe || iframe.contentWindow !== event.source ) { + return; + } + + // Update the state only if the message is formatted as we expect, i.e. + // as an object with a 'resize' action, width, and height + const { action, width, height } = data; + const { width: oldWidth, height: oldHeight } = this.state; + + if ( 'resize' === action && ( oldWidth !== width || oldHeight !== height ) ) { + this.setState( { width, height } ); + this.props.onResize(); + } + } + + onLoad( event ) { + this.maybeConnect(); + this.props.onLoad( event ); + } + + render() { + const omitProps = [ 'onResize' ]; + + if ( ! this.props.src ) { + omitProps.push( 'src' ); + } + return ( +