Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge branch 's/ckeditor5-link/1'
Browse files Browse the repository at this point in the history
Fix: Fixed a cross-site scripting (XSS) vulnerability which allowed remote attackers to inject arbitrary web script through a crafted href attribute of a link (A) element. [CVE-2018-11093](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-11093).

This issue was reported indepdentently by Toan Chi Nguyen from [Techlab Corporation](https://www.techlabcorp.com/) and [Michal Bazyli](https://www.linkedin.com/in/michal-bazyli-6a3111144/). Thank you!
  • Loading branch information
Reinmar committed May 22, 2018
2 parents 992bd11 + a8490ac commit 8cb782e
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 7 deletions.
9 changes: 7 additions & 2 deletions src/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { upcastElementToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
import LinkCommand from './linkcommand';
import UnlinkCommand from './unlinkcommand';
import { createLinkElement } from './utils';
import { createLinkElement, ensureSafeUrl } from './utils';
import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
import findLinkRange from './findlinkrange';
import '../theme/link.css';
Expand All @@ -38,9 +38,14 @@ export default class LinkEditing extends Plugin {
// Allow link attribute on all inline nodes.
editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );

editor.conversion.for( 'downcast' )
editor.conversion.for( 'dataDowncast' )
.add( downcastAttributeToElement( { model: 'linkHref', view: createLinkElement } ) );

editor.conversion.for( 'editingDowncast' )
.add( downcastAttributeToElement( { model: 'linkHref', view: ( href, writer ) => {
return createLinkElement( ensureSafeUrl( href ), writer );
} } ) );

editor.conversion.for( 'upcast' )
.add( upcastElementToAttribute( {
view: {
Expand Down
4 changes: 3 additions & 1 deletion src/ui/linkactionsview.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';

import { ensureSafeUrl } from '../utils';

import unlinkIcon from '../../theme/icons/unlink.svg';
import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
import '../../theme/linkactions.css';
Expand Down Expand Up @@ -206,7 +208,7 @@ export default class LinkActionsView extends View {
'ck',
'ck-link-actions__preview'
],
href: bind.to( 'href' ),
href: bind.to( 'href', href => href && ensureSafeUrl( href ) ),
target: '_blank'
}
} );
Expand Down
29 changes: 29 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

const linkElementSymbol = Symbol( 'linkElement' );

const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
const SAFE_URL = /^(?:(?:https?|ftps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;

/**
* Returns `true` if a given view node is the link element.
*
Expand All @@ -32,3 +35,29 @@ export function createLinkElement( href, writer ) {

return linkElement;
}

/**
* Returns a safe URL based on a given value.
*
* An URL is considered safe if it is safe for the user (does not contain any malicious code).
*
* If URL is considered unsafe, a simple `"#"` is returned.
*
* @protected
* @param {*} url
* @returns {String} Safe URL.
*/
export function ensureSafeUrl( url ) {
url = String( url );

return isSafeUrl( url ) ? url : '#';
}

// Checks whether the given URL is safe for the user (does not contain any malicious code).
//
// @param {String} url URL to check.
function isSafeUrl( url ) {
const normalizedUrl = url.replace( ATTRIBUTE_WHITESPACES, '' );

return normalizedUrl.match( SAFE_URL );
}
26 changes: 26 additions & 0 deletions tests/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ describe( 'LinkEditing', () => {

expect( editor.getData() ).to.equal( '<p><a href="">foo</a>bar</p>' );
} );

// The editor's role is not to filter out potentially malicious data.
// Its job is to not let this code be executed inside the editor (see the test in "editing pipeline conversion").
it( 'should output a link with a potential XSS code', () => {
setModelData( model, '<paragraph>[]</paragraph>' );

model.change( writer => {
writer.insertText( 'foo', { linkHref: 'javascript:alert(1)' }, model.document.selection.getFirstPosition() );
} );

expect( editor.getData() ).to.equal( '<p><a href="javascript:alert(1)">foo</a></p>' );
} );

it( 'should load a link with a potential XSS code', () => {
editor.setData( '<p><a href="javascript:alert(1)">foo</a></p>' );

expect( getModelData( model, { withoutSelection: true } ) )
.to.equal( '<paragraph><$text linkHref="javascript:alert(1)">foo</$text></paragraph>' );
} );
} );

describe( 'editing pipeline conversion', () => {
Expand Down Expand Up @@ -147,6 +166,13 @@ describe( 'LinkEditing', () => {

expect( editor.getData() ).to.equal( '<p><a href="url">a<f>b</f>c</a></p>' );
} );

it( 'must not render a link with a potential XSS code', () => {
setModelData( model, '<paragraph><$text linkHref="javascript:alert(1)">[]foo</$text>bar[]</paragraph>' );

expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
.to.equal( '<p><a href="#">foo</a>bar</p>' );
} );
} );

describe( 'link highlighting', () => {
Expand Down
8 changes: 7 additions & 1 deletion tests/ui/linkactionsview.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe( 'LinkActionsView', () => {
expect( view.previewButtonView.element.getAttribute( 'target' ) ).to.equal( '_blank' );
} );

describe( '<button> bindings', () => {
describe( '<a> bindings', () => {
it( 'binds href DOM attribute to view#href', () => {
expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.be.null;

Expand All @@ -97,6 +97,12 @@ describe( 'LinkActionsView', () => {
expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' );
} );

it( 'does not render unsafe view#href', () => {
view.href = 'javascript:alert(1)';

expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( '#' );
} );

it( 'binds #isEnabled to view#href', () => {
expect( view.previewButtonView.isEnabled ).to.be.false;

Expand Down
121 changes: 118 additions & 3 deletions tests/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeeleme
import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
import Text from '@ckeditor/ckeditor5-engine/src/view/text';

import { createLinkElement, isLinkElement } from '../src/utils';
import { createLinkElement, isLinkElement, ensureSafeUrl } from '../src/utils';

describe( 'utils', () => {
describe( 'isLinkElement', () => {
describe( 'isLinkElement()', () => {
it( 'should return true for elements created by createLinkElement', () => {
const element = createLinkElement( 'http://ckeditor.com', new ViewWriter( new ViewDocument() ) );

Expand All @@ -32,7 +32,7 @@ describe( 'utils', () => {
} );
} );

describe( 'createLinkElement', () => {
describe( 'createLinkElement()', () => {
it( 'should create link AttributeElement', () => {
const element = createLinkElement( 'http://cksource.com', new ViewWriter( new ViewDocument() ) );

Expand All @@ -42,4 +42,119 @@ describe( 'utils', () => {
expect( element.name ).to.equal( 'a' );
} );
} );

describe( 'ensureSafeUrl()', () => {
it( 'returns the same absolute http URL', () => {
const url = 'http://xx.yy/zz#foo';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same absolute https URL', () => {
const url = 'https://xx.yy/zz';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same absolute ftp URL', () => {
const url = 'ftp://xx.yy/zz';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same absolute ftps URL', () => {
const url = 'ftps://xx.yy/zz';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same absolute mailto URL', () => {
const url = 'mailto://foo@bar.com';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same relative URL (starting with a dot)', () => {
const url = './xx/yyy';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same relative URL (starting with two dots)', () => {
const url = '../../xx/yyy';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same relative URL (starting with a slash)', () => {
const url = '/xx/yyy';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same relative URL (starting with a backslash)', () => {
const url = '\\xx\\yyy';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same relative URL (starting with a letter)', () => {
const url = 'xx/yyy';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same URL even if it contains whitespaces', () => {
const url = ' ./xx/ yyy\t';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'returns the same URL even if it contains non ASCII characters', () => {
const url = 'https://kłącze.yy/źdźbło';

expect( ensureSafeUrl( url ) ).to.equal( url );
} );

it( 'accepts non string values', () => {
expect( ensureSafeUrl( undefined ) ).to.equal( 'undefined' );
expect( ensureSafeUrl( null ) ).to.equal( 'null' );
} );

it( 'returns safe URL when a malicious URL starts with javascript:', () => {
const url = 'javascript:alert(1)';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );

it( 'returns safe URL when a malicious URL starts with an unknown protocol', () => {
const url = 'foo:alert(1)';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );

it( 'returns safe URL when a malicious URL contains spaces', () => {
const url = 'java\u0000script:\talert(1)';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );

it( 'returns safe URL when a malicious URL contains spaces (2)', () => {
const url = '\u0000 javascript:alert(1)';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );

it( 'returns safe URL when a malicious URL contains a safe part', () => {
const url = 'javascript:alert(1)\nhttp://xxx';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );

it( 'returns safe URL when a malicious URL contains a safe part (2)', () => {
const url = 'javascript:alert(1);http://xxx';

expect( ensureSafeUrl( url ) ).to.equal( '#' );
} );
} );
} );

0 comments on commit 8cb782e

Please sign in to comment.