Skip to content

Commit f8b8dc8

Browse files
authored
Merge pull request #16 from sweshelo/feat/url-domain-check
feat: 表示上のURLが実際のURLと異なる際に警告
2 parents bcc3b61 + d2a617c commit f8b8dc8

File tree

4 files changed

+151
-9
lines changed

4 files changed

+151
-9
lines changed

packages/frontend/src/components/MkNote.vue

+42-2
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
7070
:enableEmojiMenu="true"
7171
:enableEmojiMenuReaction="true"
7272
/>
73+
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.shortPhishingCaution" />
7374
<div v-if="translating || translation" :class="$style.translation">
7475
<MkLoading v-if="translating" mini/>
7576
<div v-else-if="translation">
@@ -82,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
8283
<MkMediaList :mediaList="appearNote.files"/>
8384
</div>
8485
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
85-
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
86+
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="false" :class="$style.urlPreview"/>
8687
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
8788
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
8889
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -168,6 +169,7 @@ import MkPoll from '@/components/MkPoll.vue';
168169
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
169170
import MkUrlPreview from '@/components/MkUrlPreview.vue';
170171
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
172+
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
171173
import { pleaseLogin } from '@/scripts/please-login.js';
172174
import { focusPrev, focusNext } from '@/scripts/focus.js';
173175
import { checkWordMute } from '@/scripts/check-word-mute.js';
@@ -250,7 +252,8 @@ const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entiti
250252
const isMyRenote = $i && ($i.id === note.value.userId);
251253
const showContent = ref(false);
252254
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
253-
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
255+
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null);
256+
const previewUrls = computed(() => urls.value.filter(url => url.preview));
254257
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
255258
const collapsed = ref(appearNote.value.cw == null && isLong);
256259
const isDeleted = ref(false);
@@ -267,6 +270,43 @@ const renoteCollapsed = ref(
267270
)
268271
);
269272

273+
function isURL(str) {
274+
try {
275+
new URL(str);
276+
return true;
277+
} catch (error) {
278+
return false;
279+
}
280+
}
281+
282+
function getDomain(url) {
283+
try {
284+
const domain = new URL(url).hostname;
285+
return domain;
286+
} catch (error) {
287+
return null;
288+
}
289+
}
290+
291+
const isSuspectPhishingLink = computed(() => {
292+
return urls.value.some(url => {
293+
// url.textが配列でない場合、配列に変換
294+
const text = Array.isArray(url.text) ? url.text.join('') : url.text;
295+
296+
// textがURLでない場合、すぐに次のurlへ
297+
console.log(text);
298+
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;
299+
300+
// hrefとtextのドメインを比較
301+
const hrefDomain = getDomain(url.href);
302+
const textDomain = getDomain(text);
303+
console.log(hrefDomain, textDomain);
304+
305+
// ドメインが一致しない場合、フィッシングの疑いあり
306+
return hrefDomain !== textDomain && textDomain;
307+
});
308+
});
309+
270310
/* Overload FunctionにLintが対応していないのでコメントアウト
271311
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
272312
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';

packages/frontend/src/components/MkNoteDetailed.vue

+40-2
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
8383
:enableEmojiMenu="true"
8484
:enableEmojiMenuReaction="true"
8585
/>
86+
<MkPhishingCaution v-if="isSuspectPhishingLink" :cautionMessage="i18n.ts.phishingCaution" />
8687
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
8788
<div v-if="translating || translation" :class="$style.translation">
8889
<MkLoading v-if="translating" mini/>
@@ -95,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
9596
<MkMediaList :mediaList="appearNote.files"/>
9697
</div>
9798
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
98-
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
99+
<MkUrlPreview v-for="url in previewUrls" :key="url" :url="url.href" :compact="true" :detail="true" style="margin-top: 6px;"/>
99100
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
100101
</div>
101102
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
@@ -202,6 +203,7 @@ import MkPoll from '@/components/MkPoll.vue';
202203
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
203204
import MkUrlPreview from '@/components/MkUrlPreview.vue';
204205
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
206+
import MkPhishingCaution from '@/components/MkPhishingCaution.vue';
205207
import { pleaseLogin } from '@/scripts/please-login.js';
206208
import { checkWordMute } from '@/scripts/check-word-mute.js';
207209
import { userPage } from '@/filters/user.js';
@@ -274,12 +276,48 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals
274276
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
275277
const translating = ref(false);
276278
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
277-
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
279+
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url.href && appearNote.value.renote?.uri !== url.href) : null;
280+
const previewUrls = urls.filter(url => url.preview);
278281
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
279282
const conversation = ref<Misskey.entities.Note[]>([]);
280283
const replies = ref<Misskey.entities.Note[]>([]);
281284
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
282285

286+
function isURL(str) {
287+
try {
288+
new URL(str);
289+
return true;
290+
} catch (error) {
291+
return false;
292+
}
293+
}
294+
295+
function getDomain(url) {
296+
try {
297+
const domain = new URL(url).hostname;
298+
return domain;
299+
} catch (error) {
300+
return null;
301+
}
302+
}
303+
304+
const isSuspectPhishingLink = urls.some(url => {
305+
// url.textが配列でない場合、配列に変換
306+
const text = Array.isArray(url.text) ? url.text.join('') : url.text;
307+
308+
// textがURLでない場合、すぐに次のurlへ
309+
console.log(text);
310+
if (!isURL((text.startsWith('https://') || text.startsWith('http://')) ? text : `https://${text}`)) return false;
311+
312+
// hrefとtextのドメインを比較
313+
const hrefDomain = getDomain(url.href);
314+
const textDomain = getDomain(text);
315+
console.log(hrefDomain, textDomain);
316+
317+
// ドメインが一致しない場合、フィッシングの疑いあり
318+
return hrefDomain !== textDomain && textDomain;
319+
});
320+
283321
const keymap = {
284322
'r': () => reply(true),
285323
'e|a|plus': () => react(true),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!--
2+
SPDX-FileCopyrightText: syuilo and misskey-project
3+
SPDX-License-Identifier: AGPL-3.0-only
4+
-->
5+
6+
<template>
7+
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ cautionMessage }}</div>
8+
</template>
9+
10+
<script lang="ts" setup>
11+
12+
defineProps<{
13+
cautionMessage: string;
14+
}>();
15+
</script>
16+
17+
<style lang="scss" module>
18+
.root {
19+
font-size: 0.8em;
20+
padding: 8px 12px;
21+
margin-top: 10px;
22+
margin-bottom: 10px;
23+
background: var(--infoWarnBg);
24+
color: var(--error);
25+
border-radius: var(--radius);
26+
overflow: clip;
27+
}
28+
29+
.link {
30+
margin-left: 4px;
31+
color: var(--accent);
32+
}
33+
</style>

packages/frontend/src/scripts/extract-url-from-mfm.ts

+36-5
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,44 @@ const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
1212

1313
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
1414
const urlNodes = mfm.extract(nodes, (node) => {
15-
return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
15+
return (node.type === 'url') || (node.type === 'link');
1616
});
17-
const urls: string[] = unique(urlNodes.map(x => x.props.url));
17+
const urls = unique(urlNodes.map(x => {
18+
return x.type === 'url'
19+
? ({
20+
href: x.props.url,
21+
text: x.props.url,
22+
preview: true,
23+
})
24+
: ({
25+
href: x.props.url,
26+
text: extractTextValues(x.children) ?? [],
27+
preview: (!respectSilentFlag || !x.props.silent),
28+
});
29+
}));
1830

1931
return urls.reduce((array, url) => {
20-
const urlWithoutHash = removeHash(url);
21-
if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
32+
const urlWithoutHash = removeHash(url.href);
33+
if (!array.map(x => removeHash(x.href)).includes(urlWithoutHash)) array.push(url);
2234
return array;
23-
}, [] as string[]);
35+
}, []);
36+
}
37+
38+
function extractTextValues(obj: mfm.MfmNode) {
39+
const textValues = [];
40+
41+
function traverse(o) {
42+
if (Array.isArray(o)) {
43+
o.forEach(item => traverse(item));
44+
} else if (o && typeof o === 'object') {
45+
if (o.type === 'text') {
46+
textValues.push(o.props.text);
47+
} else if (o.children) {
48+
o.children.forEach(child => traverse(child));
49+
}
50+
}
51+
}
52+
53+
traverse(obj);
54+
return textValues;
2455
}

0 commit comments

Comments
 (0)