From b5a07a22df3adeb523991ccdbc23d0e285fa9c18 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 | 21 ++++--- app/javascript/mastodon/utils/sanitize.ts | 58 +++++++++++++++++++ package.json | 2 + yarn.lock | 14 ++++- 8 files changed, 101 insertions(+), 19 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 b5a30343e488c4..6d78c055e59991 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 e96bf8256339df..ad8321ffd1b19b 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 d7d688952d6405..02b9505e2897b2 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 f20d2a2d3e1561..5df94aac7eb899 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'; @@ -112,11 +113,13 @@ 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), }); } @@ -139,11 +142,13 @@ 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 00000000000000..9dbda88d9270f3 --- /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 f7201abe900a01..c1080da271333f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "css-loader": "^5.2.7", "cssnano": "^6.0.1", "detect-passive-events": "^2.0.3", + "dompurify": "^3.0.5", "dotenv": "^16.0.3", "emoji-mart": "npm:emoji-mart-lazyload@latest", "escape-html": "^1.0.3", @@ -155,6 +156,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/express": "^4.17.17", diff --git a/yarn.lock b/yarn.lock index ed8928d58ed404..3eecddf6ac4ce2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2155,6 +2155,13 @@ dependencies: "@types/node" "*" +"@types/dompurify@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.2.tgz#c1cd33a475bc49c43c2a7900e41028e2136a4553" + integrity sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ== + dependencies: + "@types/trusted-types" "*" + "@types/emoji-mart@^3.0.9": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.11.tgz#4b0f9b52dd191df13928779b55aacb2c88d201ba" @@ -2587,7 +2594,7 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== -"@types/trusted-types@^2.0.2": +"@types/trusted-types@*", "@types/trusted-types@^2.0.2": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311" integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g== @@ -5108,6 +5115,11 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" +dompurify@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.5.tgz#eb3d9cfa10037b6e73f32c586682c4b2ab01fbed" + integrity sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A== + domutils@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"