Skip to content

Commit bacdb02

Browse files
committed
feat: add read-only validation for MongoDB aggregation pipelines and corresponding tests
1 parent 7eac455 commit bacdb02

6 files changed

Lines changed: 115 additions & 4 deletions

File tree

backend/src/ai-core/tools/query-validators.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,53 @@ export function isValidSQLQuery(query: string): boolean {
2828
return true;
2929
}
3030

31+
// Aggregation operators that either write to a collection ($out, $merge) or execute
32+
// server-side JavaScript ($function, $accumulator, $where). None of these belong in a
33+
// read-only AI query, and the substring blocklist in `isValidMongoDbCommand` cannot
34+
// detect them, so they must be rejected by walking the parsed pipeline.
35+
const FORBIDDEN_MONGO_OPERATORS: ReadonlySet<string> = new Set([
36+
'$out',
37+
'$merge',
38+
'$function',
39+
'$accumulator',
40+
'$where',
41+
]);
42+
43+
function pipelineContainsForbiddenOperator(node: unknown): boolean {
44+
if (Array.isArray(node)) {
45+
return node.some((item) => pipelineContainsForbiddenOperator(item));
46+
}
47+
if (!node || typeof node !== 'object') {
48+
return false;
49+
}
50+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
51+
if (FORBIDDEN_MONGO_OPERATORS.has(key)) {
52+
return true;
53+
}
54+
if (pipelineContainsForbiddenOperator(value)) {
55+
return true;
56+
}
57+
}
58+
return false;
59+
}
60+
61+
/**
62+
* Ensures a MongoDB aggregation pipeline is read-only: it must parse as JSON and contain
63+
* no write stages (`$out`, `$merge`) or server-side JavaScript operators (`$function`,
64+
* `$accumulator`, `$where`) at any nesting depth (including `$lookup` sub-pipelines). This
65+
* AST-level check complements the substring-based `isValidMongoDbCommand`, which cannot
66+
* detect these stages. Returns false for unparseable pipelines (fail-closed).
67+
*/
68+
export function isReadOnlyMongoAggregationPipeline(pipeline: string): boolean {
69+
let parsedPipeline: unknown;
70+
try {
71+
parsedPipeline = JSON.parse(pipeline);
72+
} catch {
73+
return false;
74+
}
75+
return !pipelineContainsForbiddenOperator(parsedPipeline);
76+
}
77+
3178
export function isValidMongoDbCommand(command: string): boolean {
3279
const upperCaseCommand = command.toUpperCase();
3380
const forbiddenKeywords = ['DROP', 'REMOVE', 'UPDATE', 'INSERT', 'DELETE'];

backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import { collectMongoPipelineCollections } from '../../../ai-core/tools/collect-
2121
import { createDatabaseTools } from '../../../ai-core/tools/database-tools.js';
2222
import { searchDocumentation } from '../../../ai-core/tools/documentation-search.js';
2323
import { createDatabaseQuerySystemPrompt } from '../../../ai-core/tools/prompts.js';
24-
import { isValidMongoDbCommand, isValidSQLQuery, wrapQueryWithLimit } from '../../../ai-core/tools/query-validators.js';
24+
import {
25+
isReadOnlyMongoAggregationPipeline,
26+
isValidMongoDbCommand,
27+
isValidSQLQuery,
28+
wrapQueryWithLimit,
29+
} from '../../../ai-core/tools/query-validators.js';
2530
import { MessageBuilder } from '../../../ai-core/utils/message-builder.js';
2631
import { encodeError, encodeToToon } from '../../../ai-core/utils/toon-encoder.js';
2732
import AbstractUseCase from '../../../common/abstract-use.case.js';
@@ -298,6 +303,12 @@ export class RequestInfoFromTableWithAIUseCaseV7
298303
'Invalid MongoDB command. Please ensure it is a read-only aggregation pipeline without any forbidden keywords.',
299304
);
300305
}
306+
if (!isReadOnlyMongoAggregationPipeline(pipeline)) {
307+
throw new Error(
308+
'Invalid MongoDB command. Aggregation stages that write data ($out, $merge) or execute ' +
309+
'server-side JavaScript ($function, $accumulator, $where) are not allowed.',
310+
);
311+
}
301312
await this.assertUserCanReadPipelineCollections(
302313
pipeline,
303314
inputTableName,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { UserEntity } from '../../../entities/user/user.entity.js';
22

33
type DataKeys<T> = { [K in keyof T]: T[K] extends (...args: never[]) => unknown ? never : K }[keyof T];
4-
export type FoundUserInfoRO = Omit<Pick<UserEntity, DataKeys<UserEntity>>, 'password'>;
4+
export type FoundUserInfoRO = Omit<Pick<UserEntity, DataKeys<UserEntity>>, 'password' | 'otpSecretKey'>;
55
export type FoundUserInfoWithoutCompanyRO = Omit<FoundUserInfoRO, 'company'>;

backend/src/microservices/saas-microservice/saas.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class SaasModule {
122122
.forRoutes(
123123
{ path: 'saas/company/registered', method: RequestMethod.POST },
124124
{ path: 'saas/user/:userId', method: RequestMethod.GET },
125+
{ path: 'saas/users/email/:userEmail', method: RequestMethod.GET },
125126
{ path: 'saas/user/register', method: RequestMethod.POST },
126127
{ path: 'saas/user/demo/register', method: RequestMethod.POST },
127128
{ path: 'saas/user/google/login', method: RequestMethod.POST },

backend/src/microservices/saas-microservice/utils/build-found-user-info-ro.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { UserEntity } from '../../../entities/user/user.entity.js';
22
import { FoundUserInfoRO, FoundUserInfoWithoutCompanyRO } from '../data-structures/found-user-info.ro.js';
33

44
export function buildFoundUserInfoRO(user: UserEntity): FoundUserInfoRO {
5-
const { password: _password, ...userInfo } = user;
5+
const { password: _password, otpSecretKey: _otpSecretKey, ...userInfo } = user;
66
return userInfo;
77
}
88

99
export function buildFoundUserInfoWithoutCompanyRO(user: UserEntity): FoundUserInfoWithoutCompanyRO {
10-
const { password: _password, company: _company, ...userInfo } = user;
10+
const { password: _password, company: _company, otpSecretKey: _otpSecretKey, ...userInfo } = user;
1111
return userInfo;
1212
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import test from 'ava';
2+
import { isReadOnlyMongoAggregationPipeline } from '../../../src/ai-core/tools/query-validators.js';
3+
4+
test('allows a plain read-only pipeline', (t) => {
5+
t.true(isReadOnlyMongoAggregationPipeline('[{"$match":{"status":"active"}},{"$group":{"_id":"$type"}}]'));
6+
});
7+
8+
test('allows a $lookup read pipeline', (t) => {
9+
t.true(
10+
isReadOnlyMongoAggregationPipeline(
11+
'[{"$lookup":{"from":"orders","localField":"id","foreignField":"user_id","as":"o"}},{"$unwind":"$o"}]',
12+
),
13+
);
14+
});
15+
16+
test('rejects $out (collection overwrite)', (t) => {
17+
t.false(isReadOnlyMongoAggregationPipeline('[{"$match":{}},{"$limit":0},{"$out":"users"}]'));
18+
});
19+
20+
test('rejects $merge (collection write)', (t) => {
21+
t.false(isReadOnlyMongoAggregationPipeline('[{"$merge":{"into":"users","whenMatched":"replace"}}]'));
22+
});
23+
24+
test('rejects $where (server-side JS)', (t) => {
25+
t.false(isReadOnlyMongoAggregationPipeline('[{"$match":{"$where":"sleep(10000) || true"}}]'));
26+
});
27+
28+
test('rejects $function (server-side JS)', (t) => {
29+
t.false(
30+
isReadOnlyMongoAggregationPipeline(
31+
'[{"$addFields":{"x":{"$function":{"body":"function(){return 1;}","args":[],"lang":"js"}}}}]',
32+
),
33+
);
34+
});
35+
36+
test('rejects $accumulator (server-side JS)', (t) => {
37+
t.false(
38+
isReadOnlyMongoAggregationPipeline(
39+
'[{"$group":{"_id":"$k","v":{"$accumulator":{"init":"function(){return 0}","accumulate":"function(){}","accumulateArgs":[],"merge":"function(){}","lang":"js"}}}}]',
40+
),
41+
);
42+
});
43+
44+
test('rejects a write stage nested inside a $lookup sub-pipeline', (t) => {
45+
t.false(
46+
isReadOnlyMongoAggregationPipeline('[{"$lookup":{"from":"orders","as":"o","pipeline":[{"$out":"stolen"}]}}]'),
47+
);
48+
});
49+
50+
test('returns false (fail-closed) for an unparseable pipeline', (t) => {
51+
t.false(isReadOnlyMongoAggregationPipeline('not valid json {'));
52+
});

0 commit comments

Comments
 (0)