From 10e526ba5682fef9488d1d38ba5dfcda38619673 Mon Sep 17 00:00:00 2001 From: MeiMei <30769358+mei23@users.noreply.github.com> Date: Sun, 8 Jan 2023 20:32:17 +0900 Subject: [PATCH] fix: Escape SQL LIKE (#9493) * SQL LIKE escape * CHANGELOG --- CHANGELOG.md | 1 + packages/backend/src/misc/sql-like-escape.ts | 3 +++ .../server/api/endpoints/admin/emoji/list-remote.ts | 3 ++- .../src/server/api/endpoints/admin/emoji/list.ts | 3 ++- .../src/server/api/endpoints/admin/show-users.ts | 3 ++- .../src/server/api/endpoints/federation/instances.ts | 3 ++- .../src/server/api/endpoints/hashtags/search.ts | 3 ++- .../backend/src/server/api/endpoints/notes/search.ts | 3 ++- .../endpoints/users/search-by-username-and-host.ts | 11 ++++++----- .../backend/src/server/api/endpoints/users/search.ts | 9 +++++---- 10 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/misc/sql-like-escape.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a5db9bc96..d19545c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ You should also include the user name that made the change. - Server: アンテナの作成数上限を追加 @syuilo - Server: pages/likeのエラーIDが重複しているのを修正 @syuilo - Server: pages/updateのパラメータによってはsummaryの値が更新されないのを修正 @syuilo +- Server: Escape SQL LIKE @mei23 - Client: case insensitive emoji search @saschanaz - Client: InAppウィンドウが操作できなくなることがあるのを修正 @tamaina - Client: use proxied image for instance icon @syuilo diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts new file mode 100644 index 000000000..8470dca3d --- /dev/null +++ b/packages/backend/src/misc/sql-like-escape.ts @@ -0,0 +1,3 @@ +export function sqlLikeEscape(s: string) { + return s.replace(/([%_])/g, '\\$1'); +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index c03d27878..ed60efd7b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -5,6 +5,7 @@ import { QueryService } from '@/core/QueryService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['admin'], @@ -92,7 +93,7 @@ export default class extends Endpoint { } if (ps.query) { - q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + q.andWhere('emoji.name like :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); } const emojis = await q diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 271b14212..f357e45a5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -5,6 +5,7 @@ import type { Emoji } from '@/models/entities/Emoji.js'; import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +//import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['admin'], @@ -82,7 +83,7 @@ export default class extends Endpoint { let emojis: Emoji[]; if (ps.query) { - //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //const emojis = await q.take(ps.limit).getMany(); emojis = await q.getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 33e1be804..722e284dd 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -3,6 +3,7 @@ import type { UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['admin'], @@ -68,7 +69,7 @@ export default class extends Endpoint { } if (ps.username) { - query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + query.andWhere('user.usernameLower like :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); } if (ps.hostname) { diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 5e2f20466..726979309 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -4,6 +4,7 @@ import type { InstancesRepository } from '@/models/index.js'; import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['federation'], @@ -120,7 +121,7 @@ export default class extends Endpoint { } if (ps.host) { - query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); + query.andWhere('instance.host like :host', { host: '%' + sqlLikeEscape(ps.host.toLowerCase()) + '%' }); } const instances = await query.take(ps.limit).skip(ps.offset).getMany(); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts index 7f787ea38..6c56ef5da 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/search.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { HashtagsRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['hashtags'], @@ -37,7 +38,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const hashtags = await this.hashtagsRepository.createQueryBuilder('tag') - .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) + .where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' }) .orderBy('tag.count', 'DESC') .groupBy('tag.id') .take(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 27b477e14..02701ffe1 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -6,6 +6,7 @@ import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['notes'], @@ -70,7 +71,7 @@ export default class extends Endpoint { } query - .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('user.avatar', 'avatar') .leftJoinAndSelect('user.banner', 'banner') diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index f13df3ee9..029b1e91c 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -6,6 +6,7 @@ import type { User } from '@/models/entities/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['users'], @@ -59,10 +60,10 @@ export default class extends Endpoint { if (ps.host) { const q = this.usersRepository.createQueryBuilder('user') .where('user.isSuspended = FALSE') - .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); + .andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' }); if (ps.username) { - q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); + q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); } q.andWhere('user.updatedAt IS NOT NULL'); @@ -83,7 +84,7 @@ export default class extends Endpoint { .where(`user.id IN (${ followingQuery.getQuery() })`) .andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) .andWhere(new Brackets(qb => { qb .where('user.updatedAt IS NULL') .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); @@ -101,7 +102,7 @@ export default class extends Endpoint { .where(`user.id NOT IN (${ followingQuery.getQuery() })`) .andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) .andWhere('user.updatedAt IS NOT NULL'); otherQuery.setParameters(followingQuery.getParameters()); @@ -116,7 +117,7 @@ export default class extends Endpoint { } else { users = await this.usersRepository.createQueryBuilder('user') .where('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) .andWhere('user.updatedAt IS NOT NULL') .orderBy('user.updatedAt', 'DESC') .take(ps.limit - users.length) diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index ba0771497..25bd62126 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -5,6 +5,7 @@ import type { User } from '@/models/entities/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape'; export const meta = { tags: ['users'], @@ -57,7 +58,7 @@ export default class extends Endpoint { if (isUsername) { const usernameQuery = this.usersRepository.createQueryBuilder('user') - .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) + .where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }) .andWhere(new Brackets(qb => { qb .where('user.updatedAt IS NULL') .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); @@ -78,11 +79,11 @@ export default class extends Endpoint { } else { const nameQuery = this.usersRepository.createQueryBuilder('user') .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' }); + qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); // Also search username if it qualifies as username if (this.userEntityService.validateLocalUsername(ps.query)) { - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' }); + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); } })) .andWhere(new Brackets(qb => { qb @@ -106,7 +107,7 @@ export default class extends Endpoint { if (users.length < ps.limit) { const profQuery = this.userProfilesRepository.createQueryBuilder('prof') .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); + .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); if (ps.origin === 'local') { profQuery.andWhere('prof.userHost IS NULL');