diff --git a/.changeset/cyan-waves-kick.md b/.changeset/cyan-waves-kick.md new file mode 100644 index 0000000000..8554feda03 --- /dev/null +++ b/.changeset/cyan-waves-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Validate query string in search endpoint diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 6be32d7cd1..40af405888 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -21,13 +21,17 @@ }, "dependencies": { "@backstage/backend-common": "^0.10.4-next.0", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.2.0", "@backstage/search-common": "^0.2.0", "@backstage/plugin-search-backend-node": "^0.4.4", + "@backstage/types": "^0.1.1", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "winston": "^3.2.1", - "yn": "^4.0.0" + "yn": "^4.0.0", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/cli": "^0.12.0-next.0", diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 77a1be1eb4..407e118a8c 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -53,6 +53,39 @@ describe('createRouter', () => { expect(response.body).toMatchObject({ results: [] }); }); + it.each([ + '', + 'term=foo', + 'term=foo&extra=param', + 'types[0]=first-type', + 'types[0]=first-type&types[1]=second-type', + 'filters[prop]=value', + 'pageCursor=foo', + ])('accepts valid query string "%s"', async queryString => { + const response = await request(app).get(`/query?${queryString}`); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + results: [], + }); + }); + + it.each([ + 'term[0]=foo', + 'term[prop]=value', + 'types=foo', + 'types[length]=10000&types[0]=first-type', + 'filters=stringValue', + 'pageCursor[0]=1', + ])('rejects invalid query string "%s"', async queryString => { + const response = await request(app).get(`/query?${queryString}`); + + expect(response.status).toEqual(400); + expect(response.body).toMatchObject({ + error: { message: /invalid query string/i }, + }); + }); + describe('search result filtering', () => { beforeAll(async () => { const logger = getVoidLogger(); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index aae1914fc1..34289d6343 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -17,9 +17,28 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { SearchQuery, SearchResultSet } from '@backstage/search-common'; +import { z } from 'zod'; +import { errorHandler } from '@backstage/backend-common'; +import { InputError } from '@backstage/errors'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { SearchResultSet } from '@backstage/search-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; +const jsonObjectSchema: z.ZodSchema = z.lazy(() => { + const jsonValueSchema: z.ZodSchema = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + jsonObjectSchema, + ]), + ); + + return z.record(jsonValueSchema); +}); + export type RouterOptions = { engine: SearchEngine; logger: Logger; @@ -32,6 +51,13 @@ export async function createRouter( ): Promise { const { engine, logger } = options; + const requestSchema = z.object({ + term: z.string().default(''), + filters: jsonObjectSchema.optional(), + types: z.array(z.string()).optional(), + pageCursor: z.string().optional(), + }); + const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ ...resultSet, results: results.filter(result => { @@ -50,21 +76,26 @@ export async function createRouter( const router = Router(); router.get( '/query', - async ( - req: express.Request, - res: express.Response, - ) => { - const { term, filters = {}, types, pageCursor } = req.query; + async (req: express.Request, res: express.Response) => { + const parseResult = requestSchema.safeParse(req.query); + + if (!parseResult.success) { + throw new InputError(`Invalid query string: ${parseResult.error}`); + } + + const query = parseResult.data; + logger.info( - `Search request received: term="${term}", filters=${JSON.stringify( - filters, - )}, types=${types ? types.join(',') : ''}, pageCursor=${ - pageCursor ?? '' - }`, + `Search request received: term="${ + query.term + }", filters=${JSON.stringify(query.filters)}, types=${ + query.types ? query.types.join(',') : '' + }, pageCursor=${query.pageCursor ?? ''}`, ); try { - const resultSet = await engine?.query(req.query); + const resultSet = await engine?.query(query); + res.send(filterResultSet(resultSet)); } catch (err) { throw new Error( @@ -74,5 +105,7 @@ export async function createRouter( }, ); + router.use(errorHandler()); + return router; }