From 339157a3c1a059b33e771baec384dc7801e8f134 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 17 Mar 2026 10:34:09 +0000 Subject: [PATCH 1/2] fix: catch tsquery syntax errors (42601) as ValidationError in search queries Harden processSearchQuery to strip tsquery metacharacters from user input before constructing the query string, preventing invalid to_tsquery SQL. Add try/catch safety net at all four search execution points (bookmark suggestions, bookmark search, reading history suggestions, reading history search) to catch any remaining 42601 errors and return ValidationError instead of UNEXPECTED graphql errors. Co-Authored-By: Claude Opus 4.6 --- __tests__/bookmarks.ts | 18 +++++++++++ __tests__/common/processSearchQuery.ts | 44 ++++++++++++++++++++++++++ __tests__/users.ts | 18 +++++++++++ src/errors.ts | 1 + src/schema/bookmarks.ts | 35 ++++++++++++-------- src/schema/common.ts | 28 +++++++++++++--- src/schema/users.ts | 35 ++++++++++++++------ 7 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 __tests__/common/processSearchQuery.ts 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..f5bac609ee 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,6 +197,14 @@ 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(); @@ -203,14 +213,22 @@ export const processSearchQuery = (query: string): string => { 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, "''"); + // Strip tsquery metacharacters but preserve programming language chars (#+.-) + const stripped = trimmed.replace(/[&|!()<>:*]/g, ' ').trim(); + if (!stripped) { + throw new ValidationError('Invalid search query'); + } + const escaped = stripped.replace(/'/g, "''"); return `'${escaped}':*`; } - // For regular queries, use the original AND logic with prefix matching - return trimmed.split(' ').join(' & ') + ':*'; + // Strip tsquery metacharacters from regular queries + const stripped = trimmed.replace(/[&|!()<>:*']/g, ' ').trim(); + if (!stripped) { + throw new ValidationError('Invalid search query'); + } + + 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 }, From 374cb42b78bbd957623628f22a4aa1d5d96d54d3 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 17 Mar 2026 14:46:20 +0000 Subject: [PATCH 2/2] refactor: simplify processSearchQuery to strip metacharacters once Move the tsquery metacharacter strip and empty check before the branching logic so both paths share a single validation step. Co-Authored-By: Claude Opus 4.6 --- src/schema/common.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/schema/common.ts b/src/schema/common.ts index f5bac609ee..9690f79683 100644 --- a/src/schema/common.ts +++ b/src/schema/common.ts @@ -207,27 +207,22 @@ export const handleSearchQueryError = (error: unknown): never => { 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) { - // Strip tsquery metacharacters but preserve programming language chars (#+.-) - const stripped = trimmed.replace(/[&|!()<>:*]/g, ' ').trim(); - if (!stripped) { - throw new ValidationError('Invalid search query'); - } - const escaped = stripped.replace(/'/g, "''"); + const escaped = trimmed + .replace(/[&|!()<>:*]/g, ' ') + .trim() + .replace(/'/g, "''"); return `'${escaped}':*`; } - // Strip tsquery metacharacters from regular queries - const stripped = trimmed.replace(/[&|!()<>:*']/g, ' ').trim(); - if (!stripped) { - throw new ValidationError('Invalid search query'); - } - return stripped.split(/\s+/).join(' & ') + ':*'; };