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"