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

add sanitization to dangerouslySetInnerHTML values #530

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"brace": "^0.11.1",
"core-js": "^2.5.1",
"custom-event-polyfill": "^0.3.0",
"dompurify": "^1.0.8",
"draft-convert": "^2.0.0",
"draft-js": "^0.10.3",
"draft-js-export-html": "^1.2.0",
Expand Down
4 changes: 3 additions & 1 deletion src/react/Editor/convertToHTML.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import React from 'react';
import { convertToHTML } from 'draft-convert';

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

export default contentState =>
convertToHTML({
styleToHTML: () => {},
Expand All @@ -22,7 +24,7 @@ export default contentState =>
<div>
<div
id={`liveblog-codeblock-identifier-${entity.getData().title.replace(/\s+/g, '-')}`}
dangerouslySetInnerHTML={{ __html: entity.getData().code }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(entity.getData().code) }}
/>
</div>
);
Expand Down
7 changes: 6 additions & 1 deletion src/react/components/AuthorSelectOption.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';

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

class AuthorSelectOption extends Component {
handleMouseDown(event) {
const { onSelect, option } = this.props;
Expand Down Expand Up @@ -28,7 +30,10 @@ class AuthorSelectOption extends Component {
onMouseEnter={this.handleMouseEnter.bind(this)}
onMouseMove={this.handleMouseMove.bind(this)}
>
{ option.avatar && <div dangerouslySetInnerHTML={{ __html: option.avatar }} /> }
{
option.avatar &&
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML(option.avatar) }} />
}
{option.name}
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions src/react/components/Event.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { timeAgo } from '../utils/utils';

import { timeAgo, sanitizeHTML } from '../utils/utils';

const Event = ({ event, click, onDelete, canEdit, utcOffset, dateFormat }) => (
<li className="liveblog-event">
Expand All @@ -14,7 +15,7 @@ const Event = ({ event, click, onDelete, canEdit, utcOffset, dateFormat }) => (
<span
className="liveblog-event-content"
onClick={click}
dangerouslySetInnerHTML={{ __html: event.key_event_content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(event.key_event_content) }}
/>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/react/containers/EntryContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as apiActions from '../actions/apiActions';
import * as userActions from '../actions/userActions';
import { triggerOembedLoad, timeAgo, formattedTime } from '../utils/utils';
import { triggerOembedLoad, timeAgo, formattedTime, sanitizeHTML } from '../utils/utils';
import EditorContainer from '../containers/EditorContainer';

class EntryContainer extends Component {
Expand Down Expand Up @@ -88,10 +88,10 @@ class EntryContainer extends Component {
{ author.avatar &&
<div
className="liveblog-meta-author-avatar"
dangerouslySetInnerHTML={{ __html: author.avatar }} />
dangerouslySetInnerHTML={{ __html: sanitizeHTML(author.avatar) }} />
}
<span className="liveblog-meta-author-name"
dangerouslySetInnerHTML={{ __html: author.name }} />
dangerouslySetInnerHTML={{ __html: sanitizeHTML(author.name) }} />
</div>
))
}
Expand All @@ -107,7 +107,7 @@ class EntryContainer extends Component {
: (
<div
className="liveblog-entry-content"
dangerouslySetInnerHTML={{ __html: entry.render }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(entry.render) }}
/>
)
}
Expand Down
3 changes: 2 additions & 1 deletion src/react/containers/PreviewContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';

import { getPreview } from '../services/api';
import Loader from '../components/Loader';
import { sanitizeHTML } from '../utils/utils';

class PreviewContainer extends Component {
constructor(props) {
Expand Down Expand Up @@ -41,7 +42,7 @@ class PreviewContainer extends Component {
return (
<div
className="liveblog-preview"
dangerouslySetInnerHTML={{ __html: entryContent }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(entryContent) }}
/>
);
}
Expand Down
34 changes: 34 additions & 0 deletions src/react/utils/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';

import moment from './extendedMoment';

/* eslint-disable no-param-reassign */
Expand Down Expand Up @@ -200,3 +202,35 @@ export const getScrollToId = (entries, key) => {

return `id_${entries[0].id}`;
};

/**
* Sanitize HTML for output. Help prevent XSS attacks.
*
* @link https://www.npmjs.com/package/dompurify
* @param {string} dirty HTML to sanitize
* @return {string} sanitized HTML
*/
export const sanitizeHTML = (dirty) => {
// Whitelist iframes for the plugins 'embded media' feature.
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo in embed

const iframeWhitelist = [
'www.hulu.com',
'player.hulu.com',
'open.spotify.com',
'player.vimeo.com',
'www.youtube.com',
// Instagram and Twitter don't use iframes.
Copy link
Member

Choose a reason for hiding this comment

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

Does this mean we will strip out Twitter and Instagram embeds?

Copy link
Member

Choose a reason for hiding this comment

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

Also, how was this list of domains generated?

Copy link
Contributor Author

@davidsword davidsword Sep 26, 2018

Choose a reason for hiding this comment

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

negative. Twitter & Instagram continue to work and can be used.

Specifically, for Tweets: the Rest API has the entry content with <blockquote class="twitter-tweet">...</blockquote> and then the Twitter embed is fully rendered by using the platform.twitter.com/widgets.js script that's already enqueued into wp_head()

Copy link
Contributor Author

@davidsword davidsword Oct 9, 2018

Choose a reason for hiding this comment

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

This list of domains was generated taking liveblogs advertised media embed features, adding them to an entry, and seeing what WordPress/oembed gave back. In writing that, I realize I need to take a closer look into these domains (ie: subdomain changes for different regions or languages) and each services documentation to ensure the domains are correct and reliable for each service.

& as pointed out by @sboisvert, a filter to manage this whitelist would be needed.

];
const regex = RegExp(`^(https:|)//(${iframeWhitelist.join('|')})/`, 'im');
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'IFRAME') {
const iframeSrc = node.getAttribute('src');
if (iframeSrc && !iframeSrc.match(regex)) {
node.removeAttribute('src');
}
}
});
const clean = DOMPurify.sanitize(dirty, {
ADD_TAGS: ['iframe'],
});
return clean;
};