diff --git a/__tests__/bookmarks.ts b/__tests__/bookmarks.ts index afe0c33972..89a6799a0a 100644 --- a/__tests__/bookmarks.ts +++ b/__tests__/bookmarks.ts @@ -1241,6 +1241,15 @@ describe('query searchBookmarksSuggestions', () => { expect(res.data.searchBookmarksSuggestions.hits.length).toBeGreaterThan(0); }); + it('should return ValidationError for invalid tsquery syntax', async () => { + loggedUser = '1'; + await saveFixtures(con, Bookmark, bookmarksFixture); + const res = await client.query(QUERY('& | !')); + expect(res.errors?.length).toBe(1); + expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors[0].message).toBe('Invalid search query'); + }); + it('should handle special characters in search (C#)', async () => { loggedUser = '1'; await con.getRepository(ArticlePost).save({ @@ -1302,6 +1311,15 @@ describe('query searchBookmarks', () => { const res = await client.query(QUERY('not found')); expect(res.data).toMatchSnapshot(); }); + + it('should return ValidationError for invalid tsquery syntax', async () => { + loggedUser = '1'; + await saveFixtures(con, Bookmark, bookmarksFixture); + const res = await client.query(QUERY('((!))')); + expect(res.errors?.length).toBe(1); + expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors[0].message).toBe('Invalid search query'); + }); }); describe('mutation setBookmarkReminder', () => { diff --git a/__tests__/common/processSearchQuery.ts b/__tests__/common/processSearchQuery.ts new file mode 100644 index 0000000000..ab88cfb67c --- /dev/null +++ b/__tests__/common/processSearchQuery.ts @@ -0,0 +1,44 @@ +import { ValidationError } from 'apollo-server-errors'; +import { processSearchQuery } from '../../src/schema/common'; + +describe('processSearchQuery', () => { + it('should join words with AND operator and add prefix', () => { + expect(processSearchQuery('react vue')).toBe('react & vue:*'); + }); + + it('should handle single word', () => { + expect(processSearchQuery('react')).toBe('react:*'); + }); + + it('should trim whitespace', () => { + expect(processSearchQuery(' react ')).toBe('react:*'); + }); + + it('should preserve special characters for programming languages', () => { + expect(processSearchQuery('c++')).toBe("'c++':*"); + expect(processSearchQuery('c#')).toBe("'c#':*"); + expect(processSearchQuery('node.js')).toBe("'node.js':*"); + expect(processSearchQuery('.net')).toBe("'.net':*"); + }); + + it('should strip tsquery metacharacters from regular queries', () => { + expect(processSearchQuery('react & vue')).toBe('react & vue:*'); + expect(processSearchQuery('react | vue')).toBe('react & vue:*'); + expect(processSearchQuery('test (query)')).toBe('test & query:*'); + }); + + it('should strip tsquery metacharacters from special char queries', () => { + expect(processSearchQuery('c++ & tricks')).toBe("'c++ tricks':*"); + }); + + it('should throw ValidationError for empty query', () => { + expect(() => processSearchQuery('')).toThrow(ValidationError); + expect(() => processSearchQuery(' ')).toThrow(ValidationError); + }); + + it('should throw ValidationError when query becomes empty after stripping', () => { + expect(() => processSearchQuery('& | !')).toThrow(ValidationError); + expect(() => processSearchQuery('(())')).toThrow(ValidationError); + expect(() => processSearchQuery(':*')).toThrow(ValidationError); + }); +}); diff --git a/__tests__/users.ts b/__tests__/users.ts index 16fe085ae4..3bdc06c260 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -3621,6 +3621,14 @@ describe('query searchReadingHistorySuggestions', () => { res.data.searchReadingHistorySuggestions.hits.length, ).toBeGreaterThan(0); }); + + it('should return ValidationError for invalid tsquery syntax', async () => { + loggedUser = '1'; + const res = await client.query(QUERY('& | !')); + expect(res.errors?.length).toBe(1); + expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors[0].message).toBe('Invalid search query'); + }); }); describe('query search reading history', () => { @@ -3678,6 +3686,16 @@ describe('query search reading history', () => { expect(res.errors).toBeFalsy(); expect(res.data).toMatchSnapshot(); }); + + it('should return ValidationError for invalid tsquery syntax', async () => { + loggedUser = '1'; + const res = await client.query(QUERY, { + variables: { query: '& | !' }, + }); + expect(res.errors?.length).toBe(1); + expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.errors[0].message).toBe('Invalid search query'); + }); }); describe('mutation updateUserProfile', () => { diff --git a/src/errors.ts b/src/errors.ts index cbfa9958fd..6d2df6c03c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -127,6 +127,7 @@ export enum TypeOrmError { NULL_VIOLATION = '23502', FOREIGN_KEY = '23503', DUPLICATE_ENTRY = '23505', + SYNTAX_ERROR = '42601', USER_CONSTRAINT = 'FK_dce2a8927967051c447ae10bc8b', DEADLOCK_DETECTED = '40P01', } diff --git a/src/schema/bookmarks.ts b/src/schema/bookmarks.ts index 4a5c0fa5a3..0f20caabc2 100644 --- a/src/schema/bookmarks.ts +++ b/src/schema/bookmarks.ts @@ -3,6 +3,7 @@ import { Connection, ConnectionArguments } from 'graphql-relay'; import { getSearchQuery, GQLEmptyResponse, + handleSearchQueryError, offsetPageGenerator, Page, PageGenerator, @@ -625,8 +626,9 @@ export const resolvers: IResolvers = { { query }: { query: string }, ctx: AuthContext, ) => { - const hits: { title: string }[] = await ctx.con.query( - ` + try { + const hits: { title: string }[] = await ctx.con.query( + ` WITH search AS (${getSearchQuery('$2')}) select ts_headline(title, search.query, 'StartSel = , StopSel = ') as title @@ -638,12 +640,15 @@ export const resolvers: IResolvers = { order by views desc limit 5; `, - [ctx.userId, processSearchQuery(query)], - ); - return { - query, - hits, - }; + [ctx.userId, processSearchQuery(query)], + ); + return { + query, + hits, + }; + } catch (error) { + return handleSearchQueryError(error); + } }, searchBookmarks: async ( source, @@ -651,11 +656,15 @@ export const resolvers: IResolvers = { ctx: AuthContext, info, ): Promise & { query: string }> => { - const res = await searchResolver(source, args, ctx, info); - return { - ...res, - query: args.query, - }; + try { + const res = await searchResolver(source, args, ctx, info); + return { + ...res, + query: args.query, + }; + } catch (error) { + return handleSearchQueryError(error); + } }, }, }; diff --git a/src/schema/common.ts b/src/schema/common.ts index 0e2de0d935..9690f79683 100644 --- a/src/schema/common.ts +++ b/src/schema/common.ts @@ -1,5 +1,7 @@ import z from 'zod'; +import { ValidationError } from 'apollo-server-errors'; import { IFieldResolver, IResolvers } from '@graphql-tools/utils'; +import { TypeOrmError, TypeORMQueryFailedError } from '../errors'; import { Connection, ConnectionArguments, @@ -195,22 +197,33 @@ export interface PageGenerator< export const getSearchQuery = (param: string): string => `SELECT to_tsquery('english', ${param}) AS query`; +export const handleSearchQueryError = (error: unknown): never => { + const err = error as TypeORMQueryFailedError; + if (err?.code === TypeOrmError.SYNTAX_ERROR) { + throw new ValidationError('Invalid search query'); + } + throw error; +}; + export const processSearchQuery = (query: string): string => { const trimmed = query.trim(); + const stripped = trimmed.replace(/[&|!()<>:*']/g, ' ').trim(); - // Check if query contains special characters that should be preserved - // Common programming language patterns: c#, c++, f#, .net, node.js, etc. - const hasSpecialChars = /[#+.\-]/.test(trimmed); + if (!stripped) { + throw new ValidationError('Invalid search query'); + } + // Programming language patterns (c#, c++, .net, node.js) use phrase search + const hasSpecialChars = /[#+.\-]/.test(trimmed); if (hasSpecialChars) { - // For queries with special characters, use phrase search to preserve them - // Replace single quotes with double single quotes to escape them - const escaped = trimmed.replace(/'/g, "''"); + const escaped = trimmed + .replace(/[&|!()<>:*]/g, ' ') + .trim() + .replace(/'/g, "''"); return `'${escaped}':*`; } - // For regular queries, use the original AND logic with prefix matching - return trimmed.split(' ').join(' & ') + ':*'; + return stripped.split(/\s+/).join(' & ') + ':*'; }; export const mimirOffsetGenerator = < diff --git a/src/schema/users.ts b/src/schema/users.ts index 465bd3953e..6bfb3442f3 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -89,7 +89,12 @@ import { voteHotTake, systemUserIds, } from '../common'; -import { getSearchQuery, GQLEmptyResponse, processSearchQuery } from './common'; +import { + getSearchQuery, + GQLEmptyResponse, + handleSearchQueryError, + processSearchQuery, +} from './common'; import { ActiveView } from '../entity/ActiveView'; import graphorm from '../graphorm'; import { GraphQLResolveInfo } from 'graphql'; @@ -2370,8 +2375,9 @@ export const resolvers: IResolvers = { { query }: { query: string }, ctx: Context, ) => { - const hits: { title: string }[] = await ctx.con.query( - ` + try { + const hits: { title: string }[] = await ctx.con.query( + ` WITH search AS (${getSearchQuery('$2')}) select distinct(ts_headline(title, search.query, ('StartSel = , StopSel = '))) as title @@ -2385,19 +2391,28 @@ export const resolvers: IResolvers = { order by title desc limit 5; `, - [ctx.userId, processSearchQuery(query)], - ); - return { - query, - hits, - }; + [ctx.userId, processSearchQuery(query)], + ); + return { + query, + hits, + }; + } catch (error) { + return handleSearchQueryError(error); + } }, searchReadingHistory: async ( source, args: ConnectionArguments & { query: string }, ctx: AuthContext, info, - ): Promise> => readHistoryResolver(args, ctx, info), + ): Promise> => { + try { + return await readHistoryResolver(args, ctx, info); + } catch (error) { + return handleSearchQueryError(error); + } + }, readHistory: async ( _, args: ConnectionArguments & { isPublic?: boolean },