fix: regular expressions in word mutes (#8254)
* fix: handle regex exceptions for word mutes * add i18n strings Co-authored-by: rinsuki <428rinsuki+git@gmail.com> * stricter input validation in backend * add migration for hard mutes * fix * use correct regex library in migration * use query builder to avoid SQL injection Co-authored-by: Robin B <robflop98@outlook.com> Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
This commit is contained in:
		
							parent
							
								
									7ba5512a65
								
							
						
					
					
						commit
						afb6304979
					
				
					 6 changed files with 173 additions and 31 deletions
				
			
		|  | @ -595,6 +595,8 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" | ||||||
| smtpSecureInfo: "STARTTLS使用時はオフにします。" | smtpSecureInfo: "STARTTLS使用時はオフにします。" | ||||||
| testEmail: "配信テスト" | testEmail: "配信テスト" | ||||||
| wordMute: "ワードミュート" | wordMute: "ワードミュート" | ||||||
|  | regexpError: "正規表現エラー" | ||||||
|  | regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" | ||||||
| instanceMute: "インスタンスミュート" | instanceMute: "インスタンスミュート" | ||||||
| userSaysSomething: "{name}が何かを言いました" | userSaysSomething: "{name}が何かを言いました" | ||||||
| makeActive: "アクティブにする" | makeActive: "アクティブにする" | ||||||
|  |  | ||||||
|  | @ -0,0 +1,64 @@ | ||||||
|  | const RE2 = require('re2'); | ||||||
|  | const { MigrationInterface, QueryRunner } = require("typeorm"); | ||||||
|  | 
 | ||||||
|  | module.exports = class convertHardMutes1644010796173 { | ||||||
|  |     name = 'convertHardMutes1644010796173' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         let entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile"`); | ||||||
|  |         for(let i = 0; i < entries.length; i++) { | ||||||
|  |             let words = entries[i].mutedWords | ||||||
|  |                 .map(line => { | ||||||
|  |                     const regexp = line.join(" ").match(/^\/(.+)\/(.*)$/); | ||||||
|  |                     if (regexp) { | ||||||
|  |                         // convert regexp's
 | ||||||
|  |                         try { | ||||||
|  |                             new RE2(regexp[1], regexp[2]); | ||||||
|  |                             return `/${regexp[1]}/${regexp[2]}`; | ||||||
|  |                         } catch (err) { | ||||||
|  |                             // invalid regex, ignore it
 | ||||||
|  |                             return []; | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         // remove empty segments
 | ||||||
|  |                         return line.filter(x => x !== ''); | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 // remove empty lines
 | ||||||
|  |                 .filter(x => !(Array.isArray(x) && x.length === 0)); | ||||||
|  | 
 | ||||||
|  |             await queryRunner.connection.createQueryBuilder() | ||||||
|  |                 .update('user_profile') | ||||||
|  |                 .set({ | ||||||
|  |                     mutedWords: words | ||||||
|  |                 }) | ||||||
|  |                 .where('userId = :id', { id: entries[i].userId }) | ||||||
|  |                 .execute(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         let entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile"`); | ||||||
|  |         for(let i = 0; i < entries.length; i++) { | ||||||
|  |             let words = entries[i].mutedWords | ||||||
|  |                 .map(line => { | ||||||
|  |                     if (Array.isArray(line)) { | ||||||
|  |                         return line; | ||||||
|  |                     } else { | ||||||
|  |                     	// do not split regex at spaces again
 | ||||||
|  |                         return [line]; | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 // remove empty lines
 | ||||||
|  |                 .filter(x => !(Array.isArray(x) && x.length === 0)); | ||||||
|  | 
 | ||||||
|  |             await queryRunner.connection.createQueryBuilder() | ||||||
|  |                 .update('user_profile') | ||||||
|  |                 .set({ | ||||||
|  |                     mutedWords: words | ||||||
|  |                 }) | ||||||
|  |                 .where('userId = :id', { id: entries[i].userId }) | ||||||
|  |                 .execute(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -11,26 +11,31 @@ type UserLike = { | ||||||
| 	id: User['id']; | 	id: User['id']; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> { | export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array<string | string[]>): Promise<boolean> { | ||||||
| 	// 自分自身
 | 	// 自分自身
 | ||||||
| 	if (me && (note.userId === me.id)) return false; | 	if (me && (note.userId === me.id)) return false; | ||||||
| 
 | 
 | ||||||
| 	const words = mutedWords | 	if (mutedWords.length > 0) { | ||||||
| 		// Clean up
 |  | ||||||
| 		.map(xs => xs.filter(x => x !== '')) |  | ||||||
| 		.filter(xs => xs.length > 0); |  | ||||||
| 
 |  | ||||||
| 	if (words.length > 0) { |  | ||||||
| 		if (note.text == null) return false; | 		if (note.text == null) return false; | ||||||
| 
 | 
 | ||||||
| 		const matched = words.some(and => | 		const matched = mutedWords.some(filter => { | ||||||
| 			and.every(keyword => { | 			if (Array.isArray(filter)) { | ||||||
| 				const regexp = keyword.match(/^\/(.+)\/(.*)$/); | 				return filter.every(keyword => note.text!.includes(keyword)); | ||||||
| 				if (regexp) { | 			} else { | ||||||
|  | 				// represents RegExp
 | ||||||
|  | 				const regexp = filter.match(/^\/(.+)\/(.*)$/); | ||||||
|  | 
 | ||||||
|  | 				// This should never happen due to input sanitisation.
 | ||||||
|  | 				if (!regexp) return false; | ||||||
|  | 
 | ||||||
|  | 				try { | ||||||
| 					return new RE2(regexp[1], regexp[2]).test(note.text!); | 					return new RE2(regexp[1], regexp[2]).test(note.text!); | ||||||
|  | 				} catch (err) { | ||||||
|  | 					// This should never happen due to input sanitisation.
 | ||||||
|  | 					return false; | ||||||
| 				} | 				} | ||||||
| 				return note.text!.includes(keyword); | 			} | ||||||
| 			})); | 		}); | ||||||
| 
 | 
 | ||||||
| 		if (matched) return true; | 		if (matched) return true; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
|  | const RE2 = require('re2'); | ||||||
| import $ from 'cafy'; | import $ from 'cafy'; | ||||||
| import * as mfm from 'mfm-js'; | import * as mfm from 'mfm-js'; | ||||||
| import { ID } from '@/misc/cafy-id'; | import { ID } from '@/misc/cafy-id'; | ||||||
|  | @ -117,7 +118,7 @@ export const meta = { | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
| 		mutedWords: { | 		mutedWords: { | ||||||
| 			validator: $.optional.arr($.arr($.str)), | 			validator: $.optional.arr($.either($.arr($.str.min(1)).min(1), $.str)), | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
| 		mutedInstances: { | 		mutedInstances: { | ||||||
|  | @ -163,6 +164,12 @@ export const meta = { | ||||||
| 			code: 'NO_SUCH_PAGE', | 			code: 'NO_SUCH_PAGE', | ||||||
| 			id: '8e01b590-7eb9-431b-a239-860e086c408e', | 			id: '8e01b590-7eb9-431b-a239-860e086c408e', | ||||||
| 		}, | 		}, | ||||||
|  | 
 | ||||||
|  | 		invalidRegexp: { | ||||||
|  | 			message: 'Invalid Regular Expression.', | ||||||
|  | 			code: 'INVALID_REGEXP', | ||||||
|  | 			id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', | ||||||
|  | 		} | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	res: { | 	res: { | ||||||
|  | @ -191,6 +198,18 @@ export default define(meta, async (ps, _user, token) => { | ||||||
| 	if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; | 	if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; | ||||||
| 	if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; | 	if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; | ||||||
| 	if (ps.mutedWords !== undefined) { | 	if (ps.mutedWords !== undefined) { | ||||||
|  | 		// validate regular expression syntax
 | ||||||
|  | 		ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { | ||||||
|  | 			const regexp = x.match(/^\/(.+)\/(.*)$/); | ||||||
|  | 			if (!regexp) throw new ApiError(meta.errors.invalidRegexp); | ||||||
|  | 
 | ||||||
|  | 			try { | ||||||
|  | 				new RE2(regexp[1], regexp[2]); | ||||||
|  | 			} catch (err) { | ||||||
|  | 				throw new ApiError(meta.errors.invalidRegexp); | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
| 		profileUpdates.mutedWords = ps.mutedWords; | 		profileUpdates.mutedWords = ps.mutedWords; | ||||||
| 		profileUpdates.enableWordMute = ps.mutedWords.length > 0; | 		profileUpdates.enableWordMute = ps.mutedWords.length > 0; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -81,18 +81,65 @@ export default defineComponent({ | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	async created() { | 	async created() { | ||||||
| 		this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n'); | 		const render = (mutedWords) => mutedWords.map(x => { | ||||||
| 		this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n'); | 			if (Array.isArray(x)) { | ||||||
|  | 				return x.join(' '); | ||||||
|  | 			} else { | ||||||
|  | 				return x; | ||||||
|  | 			} | ||||||
|  | 		}).join('\n'); | ||||||
|  | 
 | ||||||
|  | 		this.softMutedWords = render(this.$store.state.mutedWords); | ||||||
|  | 		this.hardMutedWords = render(this.$i.mutedWords); | ||||||
| 
 | 
 | ||||||
| 		this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count; | 		this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count; | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	methods: { | 	methods: { | ||||||
| 		async save() { | 		async save() { | ||||||
| 			this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' '))); | 			const parseMutes = (mutes, tab) => { | ||||||
|  | 				// split into lines, remove empty lines and unnecessary whitespace | ||||||
|  | 				let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line != ''); | ||||||
|  | 
 | ||||||
|  | 				// check each line if it is a RegExp or not | ||||||
|  | 				for(let i = 0; i < lines.length; i++) { | ||||||
|  | 					const line = lines[i] | ||||||
|  | 					const regexp = line.match(/^\/(.+)\/(.*)$/); | ||||||
|  | 					if (regexp) { | ||||||
|  | 						// check that the RegExp is valid | ||||||
|  | 						try { | ||||||
|  | 							new RegExp(regexp[1], regexp[2]); | ||||||
|  | 							// note that regex lines will not be split by spaces! | ||||||
|  | 						} catch (err) { | ||||||
|  | 							// invalid syntax: do not save, do not reset changed flag | ||||||
|  | 							os.alert({ | ||||||
|  | 								type: 'error', | ||||||
|  | 								title: this.$ts.regexpError, | ||||||
|  | 								text: this.$t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString() | ||||||
|  | 							}); | ||||||
|  | 							// re-throw error so these invalid settings are not saved | ||||||
|  | 							throw err; | ||||||
|  | 						} | ||||||
|  | 					} else { | ||||||
|  | 						lines[i] = line.split(' '); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 			}; | ||||||
|  | 
 | ||||||
|  | 			let softMutes, hardMutes; | ||||||
|  | 			try { | ||||||
|  | 				softMutes = parseMutes(this.softMutedWords, this.$ts._wordMute.soft); | ||||||
|  | 				hardMutes = parseMutes(this.hardMutedWords, this.$ts._wordMute.hard); | ||||||
|  | 			} catch (err) { | ||||||
|  | 				// already displayed error message in parseMutes | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			this.$store.set('mutedWords', softMutes); | ||||||
| 			await os.api('i/update', { | 			await os.api('i/update', { | ||||||
| 				mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), | 				mutedWords: hardMutes, | ||||||
| 			}); | 			}); | ||||||
|  | 
 | ||||||
| 			this.changed = false; | 			this.changed = false; | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,23 +1,28 @@ | ||||||
| export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): boolean { | export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean { | ||||||
| 	// 自分自身
 | 	// 自分自身
 | ||||||
| 	if (me && (note.userId === me.id)) return false; | 	if (me && (note.userId === me.id)) return false; | ||||||
| 
 | 
 | ||||||
| 	const words = mutedWords | 	if (mutedWords.length > 0) { | ||||||
| 		// Clean up
 |  | ||||||
| 		.map(xs => xs.filter(x => x !== '')) |  | ||||||
| 		.filter(xs => xs.length > 0); |  | ||||||
| 
 |  | ||||||
| 	if (words.length > 0) { |  | ||||||
| 		if (note.text == null) return false; | 		if (note.text == null) return false; | ||||||
| 
 | 
 | ||||||
| 		const matched = words.some(and => | 		const matched = mutedWords.some(filter => { | ||||||
| 			and.every(keyword => { | 			if (Array.isArray(filter)) { | ||||||
| 				const regexp = keyword.match(/^\/(.+)\/(.*)$/); | 				return filter.every(keyword => note.text!.includes(keyword)); | ||||||
| 				if (regexp) { | 			} else { | ||||||
|  | 				// represents RegExp
 | ||||||
|  | 				const regexp = filter.match(/^\/(.+)\/(.*)$/); | ||||||
|  | 
 | ||||||
|  | 				// This should never happen due to input sanitisation.
 | ||||||
|  | 				if (!regexp) return false; | ||||||
|  | 
 | ||||||
|  | 				try { | ||||||
| 					return new RegExp(regexp[1], regexp[2]).test(note.text!); | 					return new RegExp(regexp[1], regexp[2]).test(note.text!); | ||||||
|  | 				} catch (err) { | ||||||
|  | 					// This should never happen due to input sanitisation.
 | ||||||
|  | 					return false; | ||||||
| 				} | 				} | ||||||
| 				return note.text!.includes(keyword); | 			} | ||||||
| 			})); | 		}); | ||||||
| 
 | 
 | ||||||
| 		if (matched) return true; | 		if (matched) return true; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue