search-backend: validate query string

Ensures that known query params accepted by the search endpoint have
the correct type after parsing

Signed-off-by: MT Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
MT Lewis
2022-01-18 12:27:14 +00:00
parent 6680853e0c
commit cd6854046e
4 changed files with 88 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Validate query string in search endpoint
+5 -1
View File
@@ -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",
@@ -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();
+45 -12
View File
@@ -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<JsonObject> = z.lazy(() => {
const jsonValueSchema: z.ZodSchema<JsonValue> = 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<express.Router> {
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<any, unknown, unknown, SearchQuery>,
res: express.Response<SearchResultSet>,
) => {
const { term, filters = {}, types, pageCursor } = req.query;
async (req: express.Request, res: express.Response<SearchResultSet>) => {
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;
}