Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow pasting an image with data URL scheme in src, if strict CSP rules are defined #8707

Merged
merged 6 commits into from
Jan 12, 2021
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
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.