diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 4a2f482a62..323b032243 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -184,6 +184,7 @@ export default { modules: { json: { sizeThreshold: parseInt(process.env.RI_JSON_SIZE_THRESHOLD, 10) || 1024, + lengthThreshold: parseInt(process.env.RI_JSON_LENGTH_THRESHOLD, 10) || -1, }, }, redis_cli: { diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 98b16d7473..ddeb265b86 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -154,6 +154,7 @@ export default { 'Encountered a timeout error while attempting to retrieve data', RDI_VALIDATION_ERROR: 'Validation error', INVALID_RDI_INSTANCE_ID: 'Invalid rdi instance id.', + UNSAFE_BIG_JSON_LENGTH: 'This JSON is too large. Try opening it with Redis Insight Desktop.', // database settings DATABASE_SETTINGS_NOT_FOUND: 'Could not find settings for this database', diff --git a/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.spec.ts b/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.spec.ts index a49d6534a1..b520c62e99 100644 --- a/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.spec.ts +++ b/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.spec.ts @@ -27,8 +27,16 @@ import { import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { mockAddFieldsDto } from 'src/modules/browser/__mocks__'; import { DatabaseService } from 'src/modules/database/database.service'; +import config, { Config } from 'src/utils/config'; import { RejsonRlService } from './rejson-rl.service'; +const mockModulesConfig = config.get('modules') as Config['modules']; + +jest.mock( + 'src/utils/config', + jest.fn(() => jest.requireActual('src/utils/config') as object), +); + const testKey = Buffer.from('somejson'); const testSerializedObject = JSON.stringify({ some: 'object' }); const testPath = '$'; @@ -40,6 +48,8 @@ describe('JsonService', () => { let databaseService: MockType; beforeEach(async () => { + mockModulesConfig.json.lengthThreshold = -1; + const module: TestingModule = await Test.createTestingModule({ providers: [ RejsonRlService, @@ -593,6 +603,27 @@ describe('JsonService', () => { ], }); }); + it('should throw an error when array length exceeds threshold', async () => { + mockModulesConfig.json.lengthThreshold = 5_000; + + when(client.sendCommand) + .calledWith( + [BrowserToolRejsonRlCommands.JsonType, testKey, testPath], + { replyEncoding: 'utf8' }, + ) + .mockReturnValue(['array']); + when(client.sendCommand) + .calledWith( + [BrowserToolRejsonRlCommands.JsonArrLen, testKey, testPath], + { replyEncoding: 'utf8' }, + ) + .mockReturnValue([5001]); + + await expect(service.getJson(mockBrowserClientMetadata, { + keyName: testKey, + path: testPath, + })).rejects.toThrow(new BadRequestException(ERROR_MESSAGES.UNSAFE_BIG_JSON_LENGTH)); + }); it('should return array with scalar values in a custom path', async () => { const path = '$["customPath"]'; const testData = [12, 'str']; @@ -774,6 +805,27 @@ describe('JsonService', () => { ], }); }); + it('should throw an error when number of object entries exceeds threshold', async () => { + mockModulesConfig.json.lengthThreshold = 5_000; + + when(client.sendCommand) + .calledWith( + [BrowserToolRejsonRlCommands.JsonType, testKey, testPath], + { replyEncoding: 'utf8' }, + ) + .mockReturnValue(['object']); + when(client.sendCommand) + .calledWith( + [BrowserToolRejsonRlCommands.JsonObjLen, testKey, testPath], + { replyEncoding: 'utf8' }, + ) + .mockReturnValue([10_000]); + + await expect(service.getJson(mockBrowserClientMetadata, { + keyName: testKey, + path: testPath, + })).rejects.toThrow(new BadRequestException(ERROR_MESSAGES.UNSAFE_BIG_JSON_LENGTH)); + }); it('should return object with scalar values as strings in a custom path', async () => { const path = '$["customPath"]'; const testData = { diff --git a/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.ts b/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.ts index 61bbe97546..5c892af2e8 100644 --- a/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.ts +++ b/redisinsight/api/src/modules/browser/rejson-rl/rejson-rl.service.ts @@ -9,7 +9,7 @@ import * as JSONBigInt from 'json-bigint'; import { AdditionalRedisModuleName, RedisErrorCodes } from 'src/constants'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { catchAclError } from 'src/utils'; -import config from 'src/utils/config'; +import config, { Config } from 'src/utils/config'; import { ClientMetadata } from 'src/common/models'; import { CreateRejsonRlWithExpireDto, @@ -34,6 +34,7 @@ import { import { RedisClient } from 'src/modules/redis/client'; import { DatabaseService } from 'src/modules/database/database.service'; +const MODULES_CONFIG = config.get('modules') as Config['modules']; const JSONbig = JSONBigInt(); @Injectable() @@ -143,6 +144,45 @@ export class RejsonRlService { return path[0] === '$' ? type[0] : type; } + private async isUnsafeBigJsonLength( + client: RedisClient, + keyName: RedisString, + path: string, + ) { + const type = await this.getJsonDataType(client, keyName, path); + + let length: number | null; + + switch (type) { + case 'object': + length = await client.sendCommand( + [BrowserToolRejsonRlCommands.JsonObjLen, keyName, path], + { replyEncoding: 'utf8' }, + ) as number; + + break; + case 'array': + length = await client.sendCommand( + [BrowserToolRejsonRlCommands.JsonArrLen, keyName, path], + { replyEncoding: 'utf8' }, + ) as number; + + break; + default: + // for the rest types we can safely continue processing + // Even for big strings since it should be handled by "sizeThreshold" before + return false; + } + + if (length === null) { + throw new BadRequestException( + `There is no such path: "${path}" in key: "${keyName}"`, + ); + } + + return MODULES_CONFIG.json.lengthThreshold > 0 && length > MODULES_CONFIG.json.lengthThreshold; + } + private async getDetails( client: RedisClient, keyName: RedisString, @@ -325,7 +365,12 @@ export class RejsonRlService { const jsonSize = await this.estimateSize(client, keyName, jsonPath); - if (jsonSize > config.get('modules')['json']['sizeThreshold']) { + if (jsonSize > MODULES_CONFIG.json.sizeThreshold) { + // Additional check for cardinality + if (await this.isUnsafeBigJsonLength(client, keyName, jsonPath)) { + throw new BadRequestException(ERROR_MESSAGES.UNSAFE_BIG_JSON_LENGTH); + } + const type = await this.getJsonDataType(client, keyName, jsonPath); result.downloaded = false; result.type = type;