Skip to content

Commit

Permalink
Merge pull request #8707 from ckeditor/i/7957
Browse files Browse the repository at this point in the history
Fix (image): Allow pasting an image with data URL scheme in src, if strict CSP rules are defined. Closes #7957.
  • Loading branch information
niegowski authored Jan 12, 2021
2 parents 2bbd265 + 402e793 commit f7a3948
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 2 deletions.
54 changes: 53 additions & 1 deletion packages/ckeditor5-image/src/imageupload/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

/* global fetch, File */

import global from '@ckeditor/ckeditor5-utils/src/dom/global';

/**
* Creates a regular expression used to test for image files.
*
Expand Down Expand Up @@ -48,7 +50,14 @@ export function fetchLocalImage( image ) {

resolve( file );
} )
.catch( reject );
.catch( err => {
// Fetch fails only, if it can't make a request due to a network failure or if anything prevented the request
// from completing, i.e. the Content Security Policy rules. It is not possible to detect the exact cause of failure,
// so we are just trying the fallback solution, if general TypeError is thrown.
return err && err.name === 'TypeError' ?
convertLocalImageOnCanvas( imageSrc ).then( resolve ).catch( reject ) :
reject( err );
} );
} );
}

Expand Down Expand Up @@ -82,3 +91,46 @@ function getImageMimeType( blob, src ) {
return 'image/jpeg';
}
}

// Creates a promise that converts the image local source (Base64 or blob) to a blob using canvas and resolves
// with a `File` object.
//
// @param {String} imageSrc Image `src` attribute value.
// @returns {Promise.<File>} A promise which resolves when an image source is converted to a `File` instance.
// It resolves with a `File` object. If there were any errors during file processing, the promise will be rejected.
function convertLocalImageOnCanvas( imageSrc ) {
return getBlobFromCanvas( imageSrc ).then( blob => {
const mimeType = getImageMimeType( blob, imageSrc );
const ext = mimeType.replace( 'image/', '' );
const filename = `image.${ ext }`;

return new File( [ blob ], filename, { type: mimeType } );
} );
}

// Creates a promise that resolves with a `Blob` object converted from the image source (Base64 or blob).
//
// @param {String} imageSrc Image `src` attribute value.
// @returns {Promise.<Blob>}
function getBlobFromCanvas( imageSrc ) {
return new Promise( ( resolve, reject ) => {
const image = global.document.createElement( 'img' );

image.addEventListener( 'load', () => {
const canvas = global.document.createElement( 'canvas' );

canvas.width = image.width;
canvas.height = image.height;

const ctx = canvas.getContext( '2d' );

ctx.drawImage( image, 0, 0 );

canvas.toBlob( blob => blob ? resolve( blob ) : reject() );
} );

image.addEventListener( 'error', () => reject() );

image.src = imageSrc;
} );
}
130 changes: 129 additions & 1 deletion packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals window, setTimeout, atob, URL, Blob, console */
/* globals document, window, setTimeout, atob, URL, Blob, HTMLCanvasElement, console */

import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';

Expand Down Expand Up @@ -923,6 +923,134 @@ describe( 'ImageUploadEditing', () => {
} );
} );

describe( 'fallback image conversion on canvas', () => {
let metaElement;
let previousMetaContent;

// Set strict Content Security Policy (CSP) rules before the first test in this block has been executed.
// The CSP rules cause that fetch() fails and it triggers the fallback procedure.
before( () => {
metaElement = document.querySelector( '[http-equiv=Content-Security-Policy]' );

if ( metaElement ) {
previousMetaContent = metaElement.getAttribute( 'content' );
} else {
metaElement = document.createElement( 'meta' );
metaElement.setAttribute( 'http-equiv', 'Content-Security-Policy' );

document.head.appendChild( metaElement );
}

metaElement.setAttribute( 'content', '' +
'default-src \'none\'; ' +
'connect-src \'self\'; ' +
'script-src \'self\'; ' +
'img-src * data: blob:;' +
'style-src \'self\' \'unsafe-inline\'; ' +
'frame-src *'
);
} );

// Remove or restore the previous CSP rules after the last test in this block has been executed.
after( () => {
if ( previousMetaContent ) {
metaElement.setAttribute( 'content', previousMetaContent );
} else {
document.head.removeChild( metaElement );
}
} );

// See https://github.com/ckeditor/ckeditor5/issues/7957.
it( 'should upload image using canvas conversion', done => {
const spy = sinon.spy();
const notification = editor.plugins.get( Notification );

notification.on( 'show:warning', evt => {
spy();
evt.stop();
}, { priority: 'high' } );

setModelData( model, '<paragraph>[]foo</paragraph>' );

const clipboardHtml = `<p>bar</p><img src=${ base64Sample } />`;
const dataTransfer = mockDataTransfer( clipboardHtml );

const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );

adapterMocks[ 0 ].loader.file.then( () => {
setTimeout( () => {
sinon.assert.notCalled( spy );
done();
} );
} ).catch( () => {
setTimeout( () => {
expect.fail( 'Promise should be resolved.' );
} );
} );
} );

it( 'should not upload and remove image if canvas conversion failed', done => {
setModelData( model, '<paragraph>[]foo</paragraph>' );

const clipboardHtml = `<img src=${ base64Sample } />`;
const dataTransfer = mockDataTransfer( clipboardHtml );

const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

// Stub `HTMLCanvasElement#toBlob` to return invalid blob, so image conversion always fails.
sinon.stub( HTMLCanvasElement.prototype, 'toBlob' ).callsFake( fn => fn( null ) );

let content = null;
editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => {
content = data.content;
} );

viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );

expectData(
'<img src="" uploadId="#loader1_id" uploadProcessed="true"></img>',
'[<image src="" uploadId="#loader1_id" uploadStatus="reading"></image>]<paragraph>foo</paragraph>',
'<paragraph>[]foo</paragraph>',
content,
done,
false
);
} );

it( 'should not show notification when image could not be loaded', done => {
const spy = sinon.spy();
const notification = editor.plugins.get( Notification );

notification.on( 'show:warning', evt => {
spy();
evt.stop();
}, { priority: 'high' } );

setModelData( model, '<paragraph>[]foo</paragraph>' );

const clipboardHtml = '<img src=data:image/png;base64,INVALID-DATA />';
const dataTransfer = mockDataTransfer( clipboardHtml );

const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) );
const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );

adapterMocks[ 0 ].loader.file.then( () => {
expect.fail( 'Promise should be rejected.' );
} ).catch( () => {
setTimeout( () => {
sinon.assert.notCalled( spy );
done();
} );
} );
} );
} );

// Helper for validating clipboard and model data as a result of a paste operation. This function checks both clipboard
// data and model data synchronously (`expectedClipboardData`, `expectedModel`) and then the model data after `loader.file`
// promise is resolved (so model state after successful/failed file fetch attempt).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<head>
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
connect-src 'self' http://*.cke-cs.com;
script-src 'self' 'unsafe-eval';
img-src * data: blob:;
style-src 'self' 'unsafe-inline';
frame-src *"
>
</head>

<div id="editor">
<h2>Paste here:</h2>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals console, window, document */

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';

import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
import Table from '@ckeditor/ckeditor5-table/src/table';
import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor';
import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor';
import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';

import PasteFromOffice from '../../../../src/pastefromoffice';

import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';

ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ ArticlePluginSet, Strikethrough, Underline, Table, TableToolbar, PageBreak,
TableProperties, TableCellProperties, EasyImage, PasteFromOffice, FontColor, FontBackgroundColor ],
toolbar: [ 'heading', '|', 'bold', 'italic', 'strikethrough', 'underline', 'link',
'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'pageBreak', 'undo', 'redo' ],
table: {
contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
},
cloudServices: CS_CONFIG
} )
.then( editor => {
window.editor = editor;
} )
.catch( err => {
console.error( err.stack );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Paste from Office

Test for Paste from Word, when strict CSP rules are configured.

Check:

1. Copy & paste some content from Word including at least one image.

0 comments on commit f7a3948

Please sign in to comment.