CVE-2023-24811: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Misskey is an open source, decentralized social media platform. In versions prior to 13.3.2 the URL preview function is subject to a cross site scripting vulnerability due to insufficient URL validation. Arbitrary JavaScript is executed when a malicious URL is loaded in the `View in Player` or `View in Window` preview. This has been fixed in version 13.3.2. Users are advised to upgrade. Users unable to upgrade should avoid usage of the `View in Player` or `View in Window` functions.

CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2023-02-22T20:15Z
Repository: https://github.com/misskey-dev/misskey
Language: Other
Files changed: CHANGELOG.md, packages/backend/src/server/web/UrlPreviewService.ts, packages/frontend/src/components/MkUrlPreview.vue, packages/frontend/src/components/MkYoutubePlayer.vue
Commit date: 2023-02-04 14:20:07 +0900
Commit message: fix(client): validate urls to improve security

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ad1e36213..66382cac12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,17 @@
 -
 
 You should also include the user name that made the change.
 -->
 
+## 13.x.x (unreleased)
+
+### Improvements
+
+### Bugfixes
+- Client: validate urls to improve security
+
 ## 13.3.1 (2023/02/04)
 
 ### Bugfixes
 - Client: カスタム絵文字にアニメーション画像を再生しない設定が適用されていない問題を修正
 - Client: オートコンプリートでUnicode絵文字がカスタム絵文字として表示されてしまうのを修正
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index 1bf88fe434..57461b7a33 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -71,10 +71,18 @@ export class UrlPreviewService {
 				followRedirects: false,
 				lang: lang ?? 'ja-JP',
 			});
 	
 			this.logger.succ(`Got preview of ${url}: ${summary.title}`);
+
+			if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
+				throw new Error('unsupported schema included');
+			}
+
+			if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
+				throw new Error('unsupported schema included');
+			}
 	
 			summary.icon = this.wrap(summary.icon);
 			summary.thumbnail = this.wrap(summary.thumbnail);
 	
 			// Cache 7days
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index f7677faf74..62e58e1553 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -1,9 +1,10 @@
 <template>
 <div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
 	<button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
-	<iframe :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
+	<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
+	<span v-else>invalid url</span>
 </div>
 <div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter">
 	<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
 </div>
 <div v-else :class="$style.urlPreview">
diff --git a/packages/frontend/src/components/MkYoutubePlayer.vue b/packages/frontend/src/components/MkYoutubePlayer.vue
index 50d38a71bd..460b038838 100644
--- a/packages/frontend/src/components/MkYoutubePlayer.vue
+++ b/packages/frontend/src/components/MkYoutubePlayer.vue
@@ -5,13 +5,14 @@
 		<span>{{ title ?? 'YouTube' }}</span>
 	</template>
 
 	<div class="poamfof">
 		<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-			<div v-if="player.url" class="player">
+			<div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
 				<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
 			</div>
+			<span v-else>invalid url</span>
 		</Transition>
 		<MkLoading v-if="fetching"/>
 		<MkError v-else-if="!player.url" @retry="ytFetch()"/>
 	</div>
 </MkWindow>

Vulnerable code: 	<iframe :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
			<div v-if="player.url" class="player">
Fixed code: ## 13.x.x (unreleased)

### Improvements

### Bugfixes
- Client: validate urls to improve security


			if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
				throw new Error('unsupported schema included');
			}

			if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
				throw new Error('unsupported schema included');
			}
	<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
	<span v-else>invalid url</span>
			<div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
			<span v-else>invalid url</span>