diff --git a/backend/package-lock.json b/backend/package-lock.json index 5e8b3d39..1ff7281b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "express": "^4.18.2", "express-async-errors": "^3.1.1", "express-rate-limit": "^7.5.0", + "express-validator": "^7.3.2", "fast-csv": "^5.0.2", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", @@ -6410,6 +6411,18 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-csv": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-5.0.2.tgz", @@ -9581,6 +9594,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11785,6 +11803,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index a0e576d3..29f254f6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,6 +53,7 @@ "express": "^4.18.2", "express-async-errors": "^3.1.1", "express-rate-limit": "^7.5.0", + "express-validator": "^7.3.2", "fast-csv": "^5.0.2", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", diff --git a/backend/src/api-tests/tabLists/queryValidation.test.ts b/backend/src/api-tests/tabLists/queryValidation.test.ts index 36877777..870f9778 100644 --- a/backend/src/api-tests/tabLists/queryValidation.test.ts +++ b/backend/src/api-tests/tabLists/queryValidation.test.ts @@ -10,7 +10,7 @@ describe('Tab list query validation and pagination', () => { beforeEach(async () => { await resetDatabase() await login() - }) + }, resetDatabaseTimeout) afterAll(async () => { await pool.end() @@ -40,6 +40,28 @@ describe('Tab list query validation and pagination', () => { ) }) + it('rejects invalid pagination for museum localities endpoint', async () => { + const { body, status } = await send<{ message: string; errors: string[] }>( + 'museum/RGM?pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A999%7D', + 'GET' + ) + + expect(status).toBe(400) + expect(body.message).toBe('Invalid query parameters') + expect(body.errors).toContain('pagination.pageSize must be an integer between 1 and 500.') + }) + + it('rejects invalid sorting for time bound time-units endpoint', async () => { + const { body, status } = await send<{ message: string; errors: string[] }>( + 'time-bound/time-units/1?sorting=%5B%7B%22id%22%3A%22invalid%22%2C%22desc%22%3Afalse%7D%5D', + 'GET' + ) + + expect(status).toBe(400) + expect(body.message).toBe('Invalid query parameters') + expect(body.errors[0]).toContain('sorting.id must be one of') + }) + it('applies pagination and sorting to reference species endpoint', async () => { const { body, status } = await send[]>( 'reference/species/10029?sorting=%5B%7B%22id%22%3A%22species_name%22%2C%22desc%22%3Atrue%7D%5D&pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A1%7D', diff --git a/backend/src/middlewares/tabListQueryValidator.ts b/backend/src/middlewares/tabListQueryValidator.ts new file mode 100644 index 00000000..0735b01a --- /dev/null +++ b/backend/src/middlewares/tabListQueryValidator.ts @@ -0,0 +1,116 @@ +import { RequestHandler } from 'express' +import { query, validationResult, ValidationChain } from 'express-validator' + +type TabListQueryValidationOptions = { + allowedSortingColumns: string[] + allowServerColumnFilters?: boolean +} + +const getSingleQueryValue = (value: unknown): string | undefined => { + if (typeof value === 'string') return value + if (Array.isArray(value) && typeof value[0] === 'string') return value[0] + return undefined +} + +const parseJsonQueryValue = (value: unknown, errorMessage: string) => { + const singleValue = getSingleQueryValue(value) + if (!singleValue) return undefined + + try { + return JSON.parse(singleValue) as unknown + } catch { + throw new Error(errorMessage) + } +} + +const validateWholeNumber = (fieldName: 'limit' | 'offset', value: unknown) => { + const singleValue = getSingleQueryValue(value) + if (!singleValue) return true + if (!/^\d+$/.test(singleValue)) throw new Error(`${fieldName} must be a non-negative integer.`) + + const parsedValue = parseInt(singleValue, 10) + if (fieldName === 'limit' && (parsedValue < 1 || parsedValue > 500)) { + throw new Error('limit must be between 1 and 500.') + } + + return true +} + +const validateSorting = (value: unknown, allowedSortingColumns: string[]) => { + const parsedValue = parseJsonQueryValue(value, 'sorting must be valid JSON.') + if (parsedValue === undefined) return true + if (!Array.isArray(parsedValue)) throw new Error('sorting must be a JSON array.') + + for (const entry of parsedValue) { + if (typeof entry !== 'object' || entry === null) { + throw new Error('sorting entries must be objects.') + } + + const candidate = entry as { id?: unknown; desc?: unknown } + if (typeof candidate.id !== 'string' || !allowedSortingColumns.includes(candidate.id)) { + throw new Error(`sorting.id must be one of: ${allowedSortingColumns.join(', ')}.`) + } + + if (candidate.desc !== undefined && typeof candidate.desc !== 'boolean') { + throw new Error('sorting.desc must be boolean when provided.') + } + } + + return true +} + +const validateColumnFilters = (value: unknown, allowServerColumnFilters: boolean) => { + const parsedValue = parseJsonQueryValue(value, 'columnfilters must be valid JSON.') + if (parsedValue === undefined) return true + if (!Array.isArray(parsedValue)) throw new Error('columnfilters must be a JSON array.') + + if (!allowServerColumnFilters && parsedValue.length > 0) { + throw new Error('Server-side columnfilters are not supported for this endpoint. Use client-side filtering.') + } + + return true +} + +const validatePagination = (value: unknown) => { + const parsedValue = parseJsonQueryValue(value, 'pagination must be valid JSON.') + if (parsedValue === undefined) return true + if (typeof parsedValue !== 'object' || parsedValue === null) throw new Error('pagination must be a JSON object.') + + const candidate = parsedValue as { pageIndex?: unknown; pageSize?: unknown } + if (!Number.isInteger(candidate.pageIndex) || (candidate.pageIndex as number) < 0) { + throw new Error('pagination.pageIndex must be a non-negative integer.') + } + + if ( + !Number.isInteger(candidate.pageSize) || + (candidate.pageSize as number) < 1 || + (candidate.pageSize as number) > 500 + ) { + throw new Error('pagination.pageSize must be an integer between 1 and 500.') + } + + return true +} + +const handleTabListValidationErrors: RequestHandler = (req, res, next) => { + const errors = validationResult(req) + if (errors.isEmpty()) return next() + + return res.status(400).send({ + message: 'Invalid query parameters', + errors: errors.array().map(error => String(error.msg)), + }) +} + +export const validateTabListQuery = ({ + allowedSortingColumns, + allowServerColumnFilters = false, +}: TabListQueryValidationOptions): Array => [ + query('sorting').custom(value => validateSorting(value, allowedSortingColumns)), + query('columnfilters').custom(value => validateColumnFilters(value, allowServerColumnFilters)), + query('columnFilters').custom(value => validateColumnFilters(value, allowServerColumnFilters)), + query('limit').custom(value => validateWholeNumber('limit', value)), + query('offset').custom(value => validateWholeNumber('offset', value)), + query('pagination').custom(validatePagination), + handleTabListValidationErrors, +] diff --git a/backend/src/routes/crossSearch.ts b/backend/src/routes/crossSearch.ts index c5a2917c..33588b5d 100644 --- a/backend/src/routes/crossSearch.ts +++ b/backend/src/routes/crossSearch.ts @@ -1,4 +1,5 @@ -import { Request, Response, Router } from 'express' +import { Request, RequestHandler, Response, Router } from 'express' +import { body, param, validationResult, ValidationChain } from 'express-validator' import { getCrossSearchRawSql, getCrossSearchLocalitiesRawSql, @@ -15,6 +16,39 @@ import { once } from 'events' const router = Router() +const parseArrayRouteParameter = (value: string, message: string) => { + const parsedValue = JSON.parse(value) as unknown + if (!Array.isArray(parsedValue)) throw new Error(message) + return true +} + +const handleRequestValidationErrors: RequestHandler = (req, res, next) => { + const errors = validationResult(req) + if (errors.isEmpty()) return next() + + const firstError = errors.array()[0] + return res.status(403).send({ error: String(firstError.msg) }) +} + +const crossSearchBodyValidators: ValidationChain[] = [ + body('limit').optional().isInt().withMessage('Limit is not a number.'), + body('offset').optional().isInt().withMessage('Offset is not a number.'), + body('columnFilters').isArray().withMessage('ColumnFilters is not an array.'), + body('sorting').isArray().withMessage('Sorting is not an array.'), +] + +const crossSearchRowsRouteValidators: ValidationChain[] = [ + param('limit').isInt().withMessage('Limit is not a number.'), + param('offset').isInt().withMessage('Offset is not a number.'), + param('columnfilters').custom(value => parseArrayRouteParameter(value as string, 'ColumnFilters is not an array.')), + param('sorting').custom(value => parseArrayRouteParameter(value as string, 'Sorting is not an array.')), +] + +const crossSearchLocalitiesRouteValidators: ValidationChain[] = [ + param('columnfilters').custom(value => parseArrayRouteParameter(value as string, 'ColumnFilters is not an array.')), + param('sorting').custom(value => parseArrayRouteParameter(value as string, 'Sorting is not an array.')), +] + const transformFunction = (row: CrossSearch & { full_count?: number }) => { const transformedRow: { [key: string]: string | number | boolean | null } = {} const keys = Object.keys(row) as Array @@ -95,29 +129,39 @@ const sendCrossSearchLocalities = async (req: Request, res: Response, parameters } } -router.post(`/all`, async (req, res) => { +router.post(`/all`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => { return sendCrossSearchRows(req, res, req.body as CrossSearchRequestParameters) }) -router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => { - return sendCrossSearchRows(req, res, { - limit: req.params.limit, - offset: req.params.offset, - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) -}) +router.get( + `/all/:limit/:offset/:columnfilters/:sorting`, + ...crossSearchRowsRouteValidators, + handleRequestValidationErrors, + async (req, res) => { + return sendCrossSearchRows(req, res, { + limit: req.params.limit, + offset: req.params.offset, + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) + } +) -router.post(`/localities`, async (req, res) => { +router.post(`/localities`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => { return sendCrossSearchLocalities(req, res, req.body as CrossSearchRequestParameters) }) -router.get(`/localities/:columnfilters/:sorting`, async (req, res) => { - return sendCrossSearchLocalities(req, res, { - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) -}) +router.get( + `/localities/:columnfilters/:sorting`, + ...crossSearchLocalitiesRouteValidators, + handleRequestValidationErrors, + async (req, res) => { + return sendCrossSearchLocalities(req, res, { + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) + } +) const streamCrossSearchExport = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { let parsedValues @@ -184,15 +228,20 @@ const streamCrossSearchExport = async (req: Request, res: Response, parameters: return stream.end() } -router.post(`/export`, async (req, res) => { +router.post(`/export`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => { return streamCrossSearchExport(req, res, req.body as CrossSearchRequestParameters) }) -router.get(`/export/:columnfilters/:sorting`, async (req, res) => { - return streamCrossSearchExport(req, res, { - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) -}) +router.get( + `/export/:columnfilters/:sorting`, + ...crossSearchLocalitiesRouteValidators, + handleRequestValidationErrors, + async (req, res) => { + return streamCrossSearchExport(req, res, { + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) + } +) export default router diff --git a/backend/src/routes/museum.ts b/backend/src/routes/museum.ts index e12c55db..f17027b2 100644 --- a/backend/src/routes/museum.ts +++ b/backend/src/routes/museum.ts @@ -5,15 +5,20 @@ import { requireOneOf } from '../middlewares/authorizer' import { Role, EditDataType, EditMetaData, Museum } from '../../../frontend/src/shared/types' import { DuplicateMuseumCodeError, writeMuseum } from '../services/write/museum' import { parseTabListQuery } from '../services/tabularQuery' +import { validateTabListQuery } from '../middlewares/tabListQueryValidator' const router = Router() +const museumLocalitiesTabQueryValidation = validateTabListQuery({ + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], +}) + router.get('/all', async (_req, res) => { const museums = await getAllMuseums() return res.status(200).send(museums) }) -router.get('/:id', async (req, res) => { +router.get('/:id', ...museumLocalitiesTabQueryValidation, async (req, res) => { const id = req.params.id const parsedQuery = parseTabListQuery({ query: req.query, diff --git a/backend/src/routes/reference.ts b/backend/src/routes/reference.ts index f2ab6fe6..b7b8d34b 100644 --- a/backend/src/routes/reference.ts +++ b/backend/src/routes/reference.ts @@ -16,6 +16,7 @@ import { Role, EditMetaData, ReferenceDetailsType, EditDataType } from '../../.. import { deleteReference, writeReference } from '../services/write/reference' import { fixBigInt } from '../utils/common' import { parseTabListQuery } from '../services/tabularQuery' +import { validateTabListQuery } from '../middlewares/tabListQueryValidator' const router = Router() @@ -61,7 +62,15 @@ router.get('/:id', async (req, res) => { return res.status(200).send(reference) }) -router.get('/localities/:id', async (req, res) => { +const referenceLocalitiesTabQueryValidation = validateTabListQuery({ + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], +}) + +const referenceSpeciesTabQueryValidation = validateTabListQuery({ + allowedSortingColumns: ['order_name', 'family_name', 'genus_name', 'species_name', 'species_id'], +}) + +router.get('/localities/:id', ...referenceLocalitiesTabQueryValidation, async (req, res) => { const id = req.params.id const parsedQuery = parseTabListQuery({ query: req.query, @@ -77,7 +86,7 @@ router.get('/localities/:id', async (req, res) => { return res.status(200).send(fixBigInt(localities)) }) -router.get('/species/:id', async (req, res) => { +router.get('/species/:id', ...referenceSpeciesTabQueryValidation, async (req, res) => { const id = req.params.id const parsedQuery = parseTabListQuery({ query: req.query, diff --git a/backend/src/routes/timeBound.ts b/backend/src/routes/timeBound.ts index 85d69b8c..fb414ebf 100644 --- a/backend/src/routes/timeBound.ts +++ b/backend/src/routes/timeBound.ts @@ -10,9 +10,14 @@ import { import { deleteTimeBound, writeTimeBound } from '../services/write/timeBound' import { fixBigInt } from '../utils/common' import { parseTabListQuery } from '../services/tabularQuery' +import { validateTabListQuery } from '../middlewares/tabListQueryValidator' const router = Router() +const timeBoundTimeUnitsTabQueryValidation = validateTabListQuery({ + allowedSortingColumns: ['tu_name', 'tu_display_name', 'rank', 'sequence', 'tu_comment', 'up_bnd', 'low_bnd'], +}) + router.get('/all', async (_req, res) => { const time_bounds = await getAllTimeBounds() return res.status(200).send(time_bounds) @@ -25,7 +30,7 @@ router.get('/:id', async (req, res) => { return res.status(200).send(timeBound) }) -router.get('/time-units/:id', async (req, res) => { +router.get('/time-units/:id', ...timeBoundTimeUnitsTabQueryValidation, async (req, res) => { const id = parseInt(req.params.id) const parsedQuery = parseTabListQuery({ query: req.query, diff --git a/backend/src/routes/timeUnit.ts b/backend/src/routes/timeUnit.ts index ed13455b..45f520c2 100644 --- a/backend/src/routes/timeUnit.ts +++ b/backend/src/routes/timeUnit.ts @@ -26,9 +26,14 @@ import { import { fixBigInt } from '../utils/common' import { writeTimeBound } from '../services/write/timeBound' import { parseTabListQuery } from '../services/tabularQuery' +import { validateTabListQuery } from '../middlewares/tabListQueryValidator' const router = Router() +const timeUnitLocalitiesTabQueryValidation = validateTabListQuery({ + allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'], +}) + router.get('/all', async (_req, res) => { try { const time_units = await getAllTimeUnits() @@ -51,7 +56,7 @@ router.get('/:id', async (req, res) => { } }) -router.get('/localities/:id', async (req, res) => { +router.get('/localities/:id', ...timeUnitLocalitiesTabQueryValidation, async (req, res) => { const id = req.params.id const parsedQuery = parseTabListQuery({ query: req.query,