Skip to content

Commit 5ef0134

Browse files
committed
feat: implement query safety validation for saved database queries
1 parent 4d0e68a commit 5ef0134

File tree

6 files changed

+459
-0
lines changed

6 files changed

+459
-0
lines changed

backend/src/entities/visualizations/saved-db-query/use-cases/create-saved-db-query.use.case.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
2+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
23
import AbstractUseCase from '../../../../common/abstract-use.case.js';
34
import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js';
45
import { BaseType } from '../../../../common/data-injection.tokens.js';
@@ -7,6 +8,7 @@ import { CreateSavedDbQueryDs } from '../data-structures/create-saved-db-query.d
78
import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js';
89
import { SavedDbQueryEntity } from '../saved-db-query.entity.js';
910
import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js';
11+
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
1012
import { ICreateSavedDbQuery } from './saved-db-query-use-cases.interface.js';
1113

1214
@Injectable({ scope: Scope.REQUEST })
@@ -33,6 +35,8 @@ export class CreateSavedDbQueryUseCase
3335
throw new NotFoundException(Messages.CONNECTION_NOT_FOUND);
3436
}
3537

38+
validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum);
39+
3640
const newQuery = new SavedDbQueryEntity();
3741
newQuery.name = name;
3842
newQuery.description = description || null;

backend/src/entities/visualizations/saved-db-query/use-cases/execute-saved-db-query.use.case.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { BaseType } from '../../../../common/data-injection.tokens.js';
77
import { Messages } from '../../../../exceptions/text/messages.js';
88
import { ExecuteSavedDbQueryDs } from '../data-structures/execute-saved-db-query.ds.js';
99
import { ExecuteSavedDbQueryResultDto } from '../dto/execute-saved-db-query-result.dto.js';
10+
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
1011
import { IExecuteSavedDbQuery } from './saved-db-query-use-cases.interface.js';
1112
import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js';
1213

@@ -40,6 +41,8 @@ export class ExecuteSavedDbQueryUseCase
4041
throw new NotFoundException(Messages.SAVED_QUERY_NOT_FOUND);
4142
}
4243

44+
validateQuerySafety(foundQuery.query_text, foundConnection.type as ConnectionTypesEnum);
45+
4346
let userEmail: string | null = null;
4447
if (isConnectionTypeAgent(foundConnection.type)) {
4548
userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId);

backend/src/entities/visualizations/saved-db-query/use-cases/test-db-query.use.case.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Messages } from '../../../../exceptions/text/messages.js';
88
import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js';
99
import { TestDbQueryDs } from '../data-structures/test-db-query.ds.js';
1010
import { TestDbQueryResultDto } from '../dto/test-db-query-result.dto.js';
11+
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
1112
import { ITestDbQuery } from './saved-db-query-use-cases.interface.js';
1213

1314
@Injectable({ scope: Scope.REQUEST })
@@ -31,6 +32,8 @@ export class TestDbQueryUseCase extends AbstractUseCase<TestDbQueryDs, TestDbQue
3132
throw new NotFoundException(Messages.CONNECTION_NOT_FOUND);
3233
}
3334

35+
validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum);
36+
3437
let userEmail: string | null = null;
3538
if (isConnectionTypeAgent(foundConnection.type)) {
3639
userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId);

backend/src/entities/visualizations/saved-db-query/use-cases/update-saved-db-query.use.case.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
2+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
23
import AbstractUseCase from '../../../../common/abstract-use.case.js';
34
import { IGlobalDatabaseContext } from '../../../../common/application/global-database-context.interface.js';
45
import { BaseType } from '../../../../common/data-injection.tokens.js';
56
import { Messages } from '../../../../exceptions/text/messages.js';
67
import { UpdateSavedDbQueryDs } from '../data-structures/update-saved-db-query.ds.js';
78
import { FoundSavedDbQueryDto } from '../dto/found-saved-db-query.dto.js';
89
import { buildFoundSavedDbQueryDto } from '../utils/build-found-saved-db-query-dto.util.js';
10+
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
911
import { IUpdateSavedDbQuery } from './saved-db-query-use-cases.interface.js';
1012
import { SavedDbQueryEntity } from '../saved-db-query.entity.js';
1113

@@ -46,6 +48,7 @@ export class UpdateSavedDbQueryUseCase
4648
foundQuery.description = description;
4749
}
4850
if (query_text !== undefined) {
51+
validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum);
4952
foundQuery.query_text = query_text;
5053
}
5154
const resultQueryText = foundQuery.query_text;
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
3+
4+
const FORBIDDEN_SQL_KEYWORDS = [
5+
'INSERT',
6+
'UPDATE',
7+
'DELETE',
8+
'MERGE',
9+
'UPSERT',
10+
'REPLACE',
11+
'CREATE',
12+
'ALTER',
13+
'DROP',
14+
'TRUNCATE',
15+
'RENAME',
16+
'GRANT',
17+
'REVOKE',
18+
'COMMIT',
19+
'ROLLBACK',
20+
'SAVEPOINT',
21+
'VACUUM',
22+
'ANALYZE',
23+
'REINDEX',
24+
'CLUSTER',
25+
'EXECUTE',
26+
'EXEC',
27+
'CALL',
28+
'DO',
29+
'COPY',
30+
'LOAD',
31+
'IMPORT',
32+
'LOCK',
33+
'UNLOCK',
34+
];
35+
36+
const FORBIDDEN_MONGODB_OPERATIONS = [
37+
'$merge',
38+
'$out',
39+
'insertOne',
40+
'insertMany',
41+
'updateOne',
42+
'updateMany',
43+
'deleteOne',
44+
'deleteMany',
45+
'replaceOne',
46+
'findOneAndDelete',
47+
'findOneAndReplace',
48+
'findOneAndUpdate',
49+
'bulkWrite',
50+
'drop',
51+
'createIndex',
52+
'dropIndex',
53+
'createCollection',
54+
'renameCollection',
55+
];
56+
57+
const FORBIDDEN_ELASTICSEARCH_OPERATIONS = [
58+
'_delete',
59+
'_update',
60+
'_bulk',
61+
'_create',
62+
'_doc',
63+
'_index',
64+
'_reindex',
65+
'_delete_by_query',
66+
'_update_by_query',
67+
];
68+
69+
const FORBIDDEN_REDIS_COMMANDS = [
70+
'SET',
71+
'DEL',
72+
'HSET',
73+
'HDEL',
74+
'LPUSH',
75+
'RPUSH',
76+
'LPOP',
77+
'RPOP',
78+
'SADD',
79+
'SREM',
80+
'ZADD',
81+
'ZREM',
82+
'FLUSHDB',
83+
'FLUSHALL',
84+
'RENAME',
85+
'EXPIRE',
86+
'PERSIST',
87+
'MOVE',
88+
'COPY',
89+
'RESTORE',
90+
'MIGRATE',
91+
'DUMP',
92+
];
93+
94+
const SQL_CONNECTION_TYPES: ConnectionTypesEnum[] = [
95+
ConnectionTypesEnum.postgres,
96+
ConnectionTypesEnum.agent_postgres,
97+
ConnectionTypesEnum.mysql,
98+
ConnectionTypesEnum.agent_mysql,
99+
ConnectionTypesEnum.mssql,
100+
ConnectionTypesEnum.agent_mssql,
101+
ConnectionTypesEnum.oracledb,
102+
ConnectionTypesEnum.agent_oracledb,
103+
ConnectionTypesEnum.ibmdb2,
104+
ConnectionTypesEnum.agent_ibmdb2,
105+
ConnectionTypesEnum.clickhouse,
106+
ConnectionTypesEnum.agent_clickhouse,
107+
ConnectionTypesEnum.cassandra,
108+
ConnectionTypesEnum.agent_cassandra,
109+
];
110+
111+
const MONGODB_CONNECTION_TYPES: ConnectionTypesEnum[] = [
112+
ConnectionTypesEnum.mongodb,
113+
ConnectionTypesEnum.agent_mongodb,
114+
];
115+
116+
const ELASTICSEARCH_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.elasticsearch];
117+
118+
const REDIS_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.redis, ConnectionTypesEnum.agent_redis];
119+
120+
const DYNAMODB_CONNECTION_TYPES: ConnectionTypesEnum[] = [ConnectionTypesEnum.dynamodb];
121+
122+
export interface QuerySafetyResult {
123+
isSafe: boolean;
124+
reason?: string;
125+
forbiddenKeyword?: string;
126+
}
127+
128+
export function checkSqlQueryIsSafe(query: string): QuerySafetyResult {
129+
if (!query || typeof query !== 'string') {
130+
return { isSafe: false, reason: 'Query is empty or invalid' };
131+
}
132+
133+
const normalizedQuery = normalizeQuery(query);
134+
135+
for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
136+
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
137+
if (regex.test(normalizedQuery)) {
138+
return {
139+
isSafe: false,
140+
reason: `Query contains forbidden keyword: ${keyword}`,
141+
forbiddenKeyword: keyword,
142+
};
143+
}
144+
}
145+
146+
const trimmedQuery = normalizedQuery.trim().toUpperCase();
147+
const allowedPrefixes = ['SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'];
148+
const startsWithAllowed = allowedPrefixes.some((prefix) => trimmedQuery.startsWith(prefix));
149+
150+
if (!startsWithAllowed) {
151+
return {
152+
isSafe: false,
153+
reason: 'Query must start with SELECT, WITH, SHOW, DESCRIBE, or EXPLAIN',
154+
};
155+
}
156+
157+
return { isSafe: true };
158+
}
159+
160+
export function checkMongoQueryIsSafe(query: string): QuerySafetyResult {
161+
if (!query || typeof query !== 'string') {
162+
return { isSafe: false, reason: 'Query is empty or invalid' };
163+
}
164+
165+
const normalizedQuery = query.toLowerCase();
166+
167+
for (const operation of FORBIDDEN_MONGODB_OPERATIONS) {
168+
if (normalizedQuery.includes(operation.toLowerCase())) {
169+
return {
170+
isSafe: false,
171+
reason: `Query contains forbidden MongoDB operation: ${operation}`,
172+
forbiddenKeyword: operation,
173+
};
174+
}
175+
}
176+
177+
return { isSafe: true };
178+
}
179+
180+
export function checkElasticsearchQueryIsSafe(query: string): QuerySafetyResult {
181+
if (!query || typeof query !== 'string') {
182+
return { isSafe: false, reason: 'Query is empty or invalid' };
183+
}
184+
185+
const normalizedQuery = query.toLowerCase();
186+
187+
for (const operation of FORBIDDEN_ELASTICSEARCH_OPERATIONS) {
188+
if (normalizedQuery.includes(operation.toLowerCase())) {
189+
return {
190+
isSafe: false,
191+
reason: `Query contains forbidden Elasticsearch operation: ${operation}`,
192+
forbiddenKeyword: operation,
193+
};
194+
}
195+
}
196+
197+
return { isSafe: true };
198+
}
199+
200+
export function checkRedisQueryIsSafe(query: string): QuerySafetyResult {
201+
if (!query || typeof query !== 'string') {
202+
return { isSafe: false, reason: 'Query is empty or invalid' };
203+
}
204+
205+
const normalizedQuery = query.toUpperCase().trim();
206+
207+
for (const command of FORBIDDEN_REDIS_COMMANDS) {
208+
const regex = new RegExp(`\\b${command}\\b`, 'i');
209+
if (regex.test(normalizedQuery)) {
210+
return {
211+
isSafe: false,
212+
reason: `Query contains forbidden Redis command: ${command}`,
213+
forbiddenKeyword: command,
214+
};
215+
}
216+
}
217+
218+
return { isSafe: true };
219+
}
220+
221+
export function checkDynamoDbQueryIsSafe(query: string): QuerySafetyResult {
222+
return checkSqlQueryIsSafe(query);
223+
}
224+
225+
export function validateQuerySafety(query: string, connectionType: ConnectionTypesEnum): void {
226+
let result: QuerySafetyResult;
227+
228+
if (MONGODB_CONNECTION_TYPES.includes(connectionType)) {
229+
result = checkMongoQueryIsSafe(query);
230+
} else if (ELASTICSEARCH_CONNECTION_TYPES.includes(connectionType)) {
231+
result = checkElasticsearchQueryIsSafe(query);
232+
} else if (REDIS_CONNECTION_TYPES.includes(connectionType)) {
233+
result = checkRedisQueryIsSafe(query);
234+
} else if (DYNAMODB_CONNECTION_TYPES.includes(connectionType)) {
235+
result = checkDynamoDbQueryIsSafe(query);
236+
} else if (SQL_CONNECTION_TYPES.includes(connectionType)) {
237+
result = checkSqlQueryIsSafe(query);
238+
} else {
239+
result = checkSqlQueryIsSafe(query);
240+
}
241+
242+
if (!result.isSafe) {
243+
throw new BadRequestException(`Unsafe query: ${result.reason}. Only read-only queries are allowed.`);
244+
}
245+
}
246+
function normalizeQuery(query: string): string {
247+
return query
248+
.replace(/--.*$/gm, '')
249+
.replace(/\/\*[\s\S]*?\*\//g, '')
250+
.replace(/\s+/g, ' ')
251+
.trim();
252+
}

0 commit comments

Comments
 (0)