From 4bc4c4c3396979a125ad8334ecf770ff4410e488 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:00:26 +0200 Subject: [PATCH] Task/harden validation of query parameters in endpoints with filters (#7809) * Harden validation of accounts, assetClasses, dataSource and tags in endpoints with filters * Update changelog --- CHANGELOG.md | 4 +++ .../api/src/app/endpoints/ai/ai.controller.ts | 5 +++- apps/api/src/app/endpoints/ai/ai.module.ts | 2 ++ .../src/app/endpoints/mcp/mcp.controller.ts | 6 ++-- apps/api/src/dtos/filter.dto.ts | 30 +++++++++++++------ apps/api/src/services/api/api.service.ts | 18 +++++------ 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0818179..f9fdd3eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Hardened the validation of the query parameters (`accounts`, `assetClasses`, `dataSource` and `tags`) in the endpoints with filters + ### Fixed - Fixed the missing country mapping of _Congo (Dem. Rep. of the)_ and _Congo (Rep. of)_ in the _Financial Modeling Prep_ service diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 1a3fd3ba2..9691e211d 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -1,6 +1,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -12,7 +13,8 @@ import { Inject, Param, Query, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -30,6 +32,7 @@ export class AiController { @Get('prompt/:mode') @HasPermission(permissions.readAiPrompt) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getPrompt( @Param('mode') mode: AiPromptMode, @Query() diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index d5cf0e3e9..844737cc6 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -7,6 +7,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -46,6 +47,7 @@ import { AiService } from './ai.service'; RedisCacheModule, SymbolProfileModule, TagModule, + TransformDataSourceInRequestModule, UserModule ], providers: [ diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index baf68659b..307724ea1 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -175,8 +175,8 @@ export class GhostfolioMcpController { }: z.infer ) { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts: accountIds?.join(','), - filterByAssetClasses: assetClasses?.join(','), + filterByAccounts: accountIds, + filterByAssetClasses: assetClasses, filterByDataSource: holding?.dataSource, filterBySymbol: holding?.symbol }); @@ -222,7 +222,7 @@ export class GhostfolioMcpController { } const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetClasses: assetClasses?.join(','), + filterByAssetClasses: assetClasses, filterByDataSource: holding?.dataSource, filterBySymbol: holding?.symbol }); diff --git a/apps/api/src/dtos/filter.dto.ts b/apps/api/src/dtos/filter.dto.ts index cb26d582b..6c50ec062 100644 --- a/apps/api/src/dtos/filter.dto.ts +++ b/apps/api/src/dtos/filter.dto.ts @@ -1,23 +1,35 @@ -import { IsOptional, IsString } from 'class-validator'; +import { AssetClass, DataSource } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { isString } from 'lodash'; export class FilterDto { @IsOptional() - @IsString() - accounts?: string; + @IsUUID(undefined, { each: true }) + @Transform(({ value }: TransformFnParams) => { + return isString(value) ? value.split(',') : value; + }) + accounts?: string[]; + @IsEnum(AssetClass, { each: true }) @IsOptional() - @IsString() - assetClasses?: string; + @Transform(({ value }: TransformFnParams) => { + return isString(value) ? value.split(',') : value; + }) + assetClasses?: AssetClass[]; + @IsEnum(DataSource) @IsOptional() - @IsString() - dataSource?: string; + dataSource?: DataSource; @IsOptional() @IsString() symbol?: string; @IsOptional() - @IsString() - tags?: string; + @IsUUID(undefined, { each: true }) + @Transform(({ value }: TransformFnParams) => { + return isString(value) ? value.split(',') : value; + }) + tags?: string[]; } diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 11074870e..9dddff85e 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -14,23 +14,23 @@ export class ApiService { filterBySymbol, filterByTags }: { - filterByAccounts?: string; - filterByAssetClasses?: string; + filterByAccounts?: string[]; + filterByAssetClasses?: string[]; filterByAssetSubClasses?: string; filterByDataSource?: string; filterByHoldingType?: string; filterBySearchQuery?: string; filterBySymbol?: string; - filterByTags?: string; + filterByTags?: string[]; }): Filter[] { - const accountIds = filterByAccounts?.split(',') ?? []; - const assetClasses = filterByAssetClasses?.split(',') ?? []; + const accountIds = filterByAccounts ?? []; + const assetClasses = filterByAssetClasses ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const dataSource = filterByDataSource; const holdingType = filterByHoldingType; const searchQuery = filterBySearchQuery?.toLowerCase(); const symbol = filterBySymbol; - const tagIds = filterByTags?.split(',') ?? []; + const tagIds = filterByTags ?? []; const filters = [ ...accountIds.map((accountId) => { @@ -96,11 +96,11 @@ export class ApiService { userSettings: UserSettings; }): Filter[] { return this.buildFiltersFromQueryParams({ - filterByAccounts: userSettings?.['filters.accounts']?.[0], - filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0], + filterByAccounts: userSettings?.['filters.accounts'], + filterByAssetClasses: userSettings?.['filters.assetClasses'], filterByDataSource: userSettings?.['filters.dataSource'], filterBySymbol: userSettings?.['filters.symbol'], - filterByTags: userSettings?.['filters.tags']?.[0] + filterByTags: userSettings?.['filters.tags'] }); } }