From cf5c58a89fcc3bf4917ab52de7b201ac81910372 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 1 Feb 2023 13:30:52 +0100 Subject: [PATCH] catalog-backend: validate cursor Signed-off-by: Vincenzo Scamporlino --- .../service/DefaultEntitiesCatalog.test.ts | 20 +++++++++++ .../src/service/DefaultEntitiesCatalog.ts | 34 ++++++++++++++++--- plugins/catalog-backend/src/service/util.ts | 10 +++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index bde55ef9bb..a25cbf2240 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -1294,6 +1294,26 @@ describe('DefaultEntitiesCatalog', () => { expect(response5.totalItems).toBe(6); }, ); + + it('should throw in case of malformed cursor', async () => { + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: getVoidLogger(), + stitcher, + }); + + const cursor = Buffer.from( + JSON.stringify({ filter: 'notavalidfilter' }), + 'utf8', + ).toString('base64'); + const request: PaginatedEntitiesCursorRequest = { + cursor, + }; + + await expect(catalog.paginatedEntities(request)).rejects.toThrow( + Error('Malformed cursor, could not be parsed'), + ); + }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 0cc791e5de..67fe77cc8b 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -23,6 +23,7 @@ import { InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { isEqual, chunk as lodashChunk } from 'lodash'; import { Logger } from 'winston'; +import { z } from 'zod'; import { EntitiesBatchRequest, EntitiesBatchResponse, @@ -658,11 +659,39 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } } +const entityFilterParser: z.ZodSchema = z.lazy(() => + z + .object({ + key: z.string(), + values: z.array(z.string()).optional(), + }) + .or(z.object({ not: entityFilterParser })) + .or(z.object({ anyOf: z.array(entityFilterParser) })) + .or(z.object({ allOf: z.array(entityFilterParser) })), +); + +export const cursorParser: z.ZodSchema = z.object({ + sortFields: z.array( + z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), + ), + sortFieldValues: z.array(z.string().or(z.null())), + filter: entityFilterParser.optional(), + isPrevious: z.boolean(), + query: z.string().optional(), + firstSortFieldValues: z.array(z.string().or(z.null())).optional(), + totalItems: z.number().optional(), +}); + function encodeCursor(cursor: Cursor) { const json = JSON.stringify(cursor); return Buffer.from(json, 'utf8').toString('base64'); } +function decodeCursor(encodedCursor: string) { + const json = Buffer.from(encodedCursor, 'base64').toString('utf8'); + return cursorParser.parse(JSON.parse(json)); +} + function parseCursorFromRequest( request?: PaginatedEntitiesRequest, ): Partial { @@ -672,10 +701,7 @@ function parseCursorFromRequest( } if (isPaginatedEntitiesCursorRequest(request)) { try { - const json = Buffer.from(request.cursor, 'base64').toString('utf8'); - const cursor = JSON.parse(json); - // TODO(vinzscam): validate the shit - return cursor as unknown as Cursor; + return decodeCursor(request.cursor); } catch { throw new InputError('Malformed cursor, could not be parsed'); } diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index bb86654565..958b4153f8 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -19,9 +19,9 @@ import { Request } from 'express'; import lodash from 'lodash'; import { z } from 'zod'; import { - EntitiesRequest, PaginatedEntitiesCursorRequest, PaginatedEntitiesInitialRequest, + PaginatedEntitiesRequest, } from '../catalog/types'; export async function requireRequestBody(req: Request): Promise { @@ -72,21 +72,19 @@ export function disallowReadonlyMode(readonly: boolean) { } export function isPaginatedEntitiesInitialRequest( - input: EntitiesRequest | undefined, + input: PaginatedEntitiesRequest | undefined, ): input is PaginatedEntitiesInitialRequest { if (!input) { return false; } - // TODO(vinzscam) expand this return !isPaginatedEntitiesCursorRequest(input); } export function isPaginatedEntitiesCursorRequest( - input: EntitiesRequest | undefined, + input: PaginatedEntitiesRequest | undefined, ): input is PaginatedEntitiesCursorRequest { if (!input) { return false; } - // TODO(vinzscam) expand this - return input?.hasOwnProperty('cursor') ?? false; + return !!(input as PaginatedEntitiesCursorRequest).cursor; }