From 7d4a1faa4c65cdfcfab28d57b4061b8b5fc1f28f Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 11 Jul 2023 14:14:33 +0200 Subject: [PATCH] Add client-side sanitization of server-provided HTML --- .../mastodon/actions/importer/normalizer.js | 11 ++-- .../mastodon/components/verified_badge.tsx | 4 +- .../features/status/components/card.jsx | 3 +- .../ui/components/compare_history_modal.jsx | 7 ++- app/javascript/mastodon/models/account.ts | 15 +++-- app/javascript/mastodon/utils/sanitize.ts | 58 +++++++++++++++++++ package.json | 2 + yarn.lock | 25 ++++++++ 8 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 app/javascript/mastodon/utils/sanitize.ts diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index b5a30343e48..6d78c055e59 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -2,6 +2,7 @@ import escapeTextContentForBrowser from 'escape-html'; import emojify from '../../features/emoji/emoji'; import { expandSpoilers } from '../../initial_state'; +import { sanitize } from '../../utils/sanitize'; const domParser = new DOMParser(); @@ -66,8 +67,8 @@ export function normalizeStatus(status, normalOldStatus) { const emojiMap = makeEmojiMap(normalStatus.emojis); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); + normalStatus.contentHtml = sanitize(emojify(normalStatus.content, emojiMap)); + normalStatus.spoilerHtml = sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap)); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; } @@ -93,8 +94,8 @@ export function normalizeStatusTranslation(translation, status) { detected_source_language: translation.detected_source_language, language: translation.language, provider: translation.provider, - contentHtml: emojify(translation.content, emojiMap), - spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), + contentHtml: sanitize(emojify(translation.content, emojiMap)), + spoilerHtml: sanitize(emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap)), spoiler_text: translation.spoiler_text, }; @@ -137,7 +138,7 @@ export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; const emojiMap = makeEmojiMap(normalAnnouncement.emojis); - normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + normalAnnouncement.contentHtml = sanitize(emojify(normalAnnouncement.content, emojiMap)); return normalAnnouncement; } diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx index e96bf825633..ad8321ffd1b 100644 --- a/app/javascript/mastodon/components/verified_badge.tsx +++ b/app/javascript/mastodon/components/verified_badge.tsx @@ -1,5 +1,7 @@ import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/check.svg'; +import { sanitize } from 'mastodon/utils/sanitize'; + import { Icon } from './icon'; const domParser = new DOMParser(); @@ -15,7 +17,7 @@ const stripRelMe = (html: string) => { }); const body = document.querySelector('body'); - return body ? { __html: body.innerHTML } : undefined; + return body ? { __html: sanitize(body.innerHTML) } : undefined; }; interface Props { diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx index d7d688952d6..02b9505e289 100644 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ b/app/javascript/mastodon/features/status/components/card.jsx @@ -18,6 +18,7 @@ import { Blurhash } from 'mastodon/components/blurhash'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { useBlurhash } from 'mastodon/initial_state'; +import { sanitize_oembed } from 'mastodon/utils/sanitize'; const IDNA_PREFIX = 'xn--'; @@ -109,7 +110,7 @@ export default class Card extends PureComponent { renderVideo () { const { card } = this.props; - const content = { __html: addAutoPlay(card.get('html')) }; + const content = { __html: sanitize_oembed(addAutoPlay(card.get('html'))) }; return (
({ language: state.getIn(['statuses', statusId, 'language']), @@ -51,8 +52,8 @@ class CompareHistoryModal extends PureComponent { return obj; }, {}); - const content = { __html: emojify(currentVersion.get('content'), emojiMap) }; - const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) }; + const content = { __html: sanitize(emojify(currentVersion.get('content'), emojiMap)) }; + const spoilerContent = { __html: sanitize(emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap)) }; const formattedDate = ; const formattedName = ; @@ -90,7 +91,7 @@ class CompareHistoryModal extends PureComponent { diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index a04ebe62915..723c1ba6817 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -11,6 +11,7 @@ import type { import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji'; import emojify from 'mastodon/features/emoji/emoji'; import { unescapeHTML } from 'mastodon/utils/html'; +import { sanitize } from 'mastodon/utils/sanitize'; import { CustomEmojiFactory } from './custom_emoji'; import type { CustomEmoji } from './custom_emoji'; @@ -114,11 +115,10 @@ function createAccountField( ) { return AccountFieldFactory({ ...jsonField, - name_emojified: emojify( - escapeTextContentForBrowser(jsonField.name), - emojiMap, + name_emojified: sanitize( + emojify(escapeTextContentForBrowser(jsonField.name), emojiMap), ), - value_emojified: emojify(jsonField.value, emojiMap), + value_emojified: sanitize(emojify(jsonField.value, emojiMap)), value_plain: unescapeHTML(jsonField.value), }); } @@ -141,11 +141,10 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) { ), emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))), roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))), - display_name_html: emojify( - escapeTextContentForBrowser(displayName), - emojiMap, + display_name_html: sanitize( + emojify(escapeTextContentForBrowser(displayName), emojiMap), ), - note_emojified: emojify(accountJSON.note, emojiMap), + note_emojified: sanitize(emojify(accountJSON.note, emojiMap)), note_plain: unescapeHTML(accountJSON.note), }); } diff --git a/app/javascript/mastodon/utils/sanitize.ts b/app/javascript/mastodon/utils/sanitize.ts new file mode 100644 index 00000000000..9dbda88d927 --- /dev/null +++ b/app/javascript/mastodon/utils/sanitize.ts @@ -0,0 +1,58 @@ +import DOMPurify from 'dompurify'; + +const default_config = { + ALLOWED_TAGS: [ + 'p', + 'br', + 'span', + 'a', + 'del', + 'pre', + 'blockquote', + 'code', + 'b', + 'strong', + 'u', + 'i', + 'em', + 'ul', + 'ol', + 'li', + 'img', + ], + ALLOWED_ATTR: [ + 'src', + 'alt', + 'title', + 'draggable', + 'href', + 'rel', + 'class', + 'translate', + 'start', + 'reversed', + 'value', + 'target', + ], +}; + +const oembed_config = { + ALLOWED_TAGS: ['audio', 'embed', 'iframe', 'source', 'video'], + ALLOWED_ATTR: [ + 'controls', + 'width', + 'height', + 'src', + 'type', + 'allowfullscreen', + 'frameborder', + 'scrolling', + 'loop', + 'sandbox', + ], +}; + +export const sanitize = (src: string) => + DOMPurify.sanitize(src, default_config); +export const sanitize_oembed = (src: string) => + DOMPurify.sanitize(src, oembed_config); diff --git a/package.json b/package.json index a5b6a228ca4..1f5c2ee7e1d 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "css-loader": "^5.2.7", "cssnano": "^6.0.1", "detect-passive-events": "^2.0.3", + "dompurify": "^3.0.5", "emoji-mart": "npm:emoji-mart-lazyload@latest", "escape-html": "^1.0.3", "file-loader": "^6.2.0", @@ -150,6 +151,7 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@types/babel__core": "^7.20.1", + "@types/dompurify": "^3.0.2", "@types/emoji-mart": "^3.0.9", "@types/escape-html": "^1.0.2", "@types/hoist-non-react-statics": "^3.3.1", diff --git a/yarn.lock b/yarn.lock index e1c8ab3fb88..723aeb470a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2307,6 +2307,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" "@types/babel__core": "npm:^7.20.1" + "@types/dompurify": "npm:^3.0.2" "@types/emoji-mart": "npm:^3.0.9" "@types/escape-html": "npm:^1.0.2" "@types/hoist-non-react-statics": "npm:^3.3.1" @@ -2360,6 +2361,7 @@ __metadata: css-loader: "npm:^5.2.7" cssnano: "npm:^6.0.1" detect-passive-events: "npm:^2.0.3" + dompurify: "npm:^3.0.5" emoji-mart: "npm:emoji-mart-lazyload@latest" escape-html: "npm:^1.0.3" eslint: "npm:^8.41.0" @@ -3038,6 +3040,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.2": + version: 3.0.5 + resolution: "@types/dompurify@npm:3.0.5" + dependencies: + "@types/trusted-types": "npm:*" + checksum: a34dcc4498ca250815ccf9aecbe82df96ba5db247d0440cf266a876757d47c52519c240db3475e794d7deb0d6b1af23328e02879be368ad0e26b20c0f0865dba + languageName: node + linkType: hard + "@types/emoji-mart@npm:^3.0.9": version: 3.0.14 resolution: "@types/emoji-mart@npm:3.0.14" @@ -3592,6 +3603,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:*": + version: 2.0.6 + resolution: "@types/trusted-types@npm:2.0.6" + checksum: 8d942c25bfabd89463170e22f0b3312b776885735a9c259495266b90c590f040b2112cb25e05cc2dee6e397301597b979b8ea8b0d10f2232adf38c542a16324b + languageName: node + linkType: hard + "@types/trusted-types@npm:^2.0.2": version: 2.0.3 resolution: "@types/trusted-types@npm:2.0.3" @@ -6904,6 +6922,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.0.5": + version: 3.0.6 + resolution: "dompurify@npm:3.0.6" + checksum: defc5126e1724bbe5dd5835f0de838c6dc9726a73fc74893e4c661a3c1bd5c65189295013afee74ae7097b3be93499539ff9ec66118d3aa46e788266b1f7514c + languageName: node + linkType: hard + "domutils@npm:^1.7.0": version: 1.7.0 resolution: "domutils@npm:1.7.0"