Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/src/ai-core/tools/query-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,53 @@ export function isValidSQLQuery(query: string): boolean {
return true;
}

// Aggregation operators that either write to a collection ($out, $merge) or execute
// server-side JavaScript ($function, $accumulator, $where). None of these belong in a
// read-only AI query, and the substring blocklist in `isValidMongoDbCommand` cannot
// detect them, so they must be rejected by walking the parsed pipeline.
const FORBIDDEN_MONGO_OPERATORS: ReadonlySet<string> = new Set([
'$out',
'$merge',
'$function',
'$accumulator',
'$where',
]);

function pipelineContainsForbiddenOperator(node: unknown): boolean {
if (Array.isArray(node)) {
return node.some((item) => pipelineContainsForbiddenOperator(item));
}
if (!node || typeof node !== 'object') {
return false;
}
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (FORBIDDEN_MONGO_OPERATORS.has(key)) {
return true;
}
if (pipelineContainsForbiddenOperator(value)) {
return true;
}
}
return false;
}

/**
* Ensures a MongoDB aggregation pipeline is read-only: it must parse as JSON and contain
* no write stages (`$out`, `$merge`) or server-side JavaScript operators (`$function`,
* `$accumulator`, `$where`) at any nesting depth (including `$lookup` sub-pipelines). This
* AST-level check complements the substring-based `isValidMongoDbCommand`, which cannot
* detect these stages. Returns false for unparseable pipelines (fail-closed).
*/
export function isReadOnlyMongoAggregationPipeline(pipeline: string): boolean {
let parsedPipeline: unknown;
try {
parsedPipeline = JSON.parse(pipeline);
} catch {
return false;
}
return !pipelineContainsForbiddenOperator(parsedPipeline);
}
Comment on lines +68 to +76

export function isValidMongoDbCommand(command: string): boolean {
const upperCaseCommand = command.toUpperCase();
const forbiddenKeywords = ['DROP', 'REMOVE', 'UPDATE', 'INSERT', 'DELETE'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { collectMongoPipelineCollections } from '../../../ai-core/tools/collect-
import { createDatabaseTools } from '../../../ai-core/tools/database-tools.js';
import { searchDocumentation } from '../../../ai-core/tools/documentation-search.js';
import { createDatabaseQuerySystemPrompt } from '../../../ai-core/tools/prompts.js';
import { isValidMongoDbCommand, isValidSQLQuery, wrapQueryWithLimit } from '../../../ai-core/tools/query-validators.js';
import {
isReadOnlyMongoAggregationPipeline,
isValidMongoDbCommand,
isValidSQLQuery,
wrapQueryWithLimit,
} from '../../../ai-core/tools/query-validators.js';
import { MessageBuilder } from '../../../ai-core/utils/message-builder.js';
import { encodeError, encodeToToon } from '../../../ai-core/utils/toon-encoder.js';
import AbstractUseCase from '../../../common/abstract-use.case.js';
Expand Down Expand Up @@ -298,6 +303,12 @@ export class RequestInfoFromTableWithAIUseCaseV7
'Invalid MongoDB command. Please ensure it is a read-only aggregation pipeline without any forbidden keywords.',
);
}
if (!isReadOnlyMongoAggregationPipeline(pipeline)) {
throw new Error(
'Invalid MongoDB command. Aggregation stages that write data ($out, $merge) or execute ' +
'server-side JavaScript ($function, $accumulator, $where) are not allowed.',
);
}
await this.assertUserCanReadPipelineCollections(
pipeline,
inputTableName,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UserEntity } from '../../../entities/user/user.entity.js';

type DataKeys<T> = { [K in keyof T]: T[K] extends (...args: never[]) => unknown ? never : K }[keyof T];
export type FoundUserInfoRO = Omit<Pick<UserEntity, DataKeys<UserEntity>>, 'password'>;
export type FoundUserInfoRO = Omit<Pick<UserEntity, DataKeys<UserEntity>>, 'password' | 'otpSecretKey'>;
export type FoundUserInfoWithoutCompanyRO = Omit<FoundUserInfoRO, 'company'>;
1 change: 1 addition & 0 deletions backend/src/microservices/saas-microservice/saas.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class SaasModule {
.forRoutes(
{ path: 'saas/company/registered', method: RequestMethod.POST },
{ path: 'saas/user/:userId', method: RequestMethod.GET },
{ path: 'saas/users/email/:userEmail', method: RequestMethod.GET },
{ path: 'saas/user/register', method: RequestMethod.POST },
Comment on lines 122 to 126
{ path: 'saas/user/demo/register', method: RequestMethod.POST },
{ path: 'saas/user/google/login', method: RequestMethod.POST },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { UserEntity } from '../../../entities/user/user.entity.js';
import { FoundUserInfoRO, FoundUserInfoWithoutCompanyRO } from '../data-structures/found-user-info.ro.js';

export function buildFoundUserInfoRO(user: UserEntity): FoundUserInfoRO {
const { password: _password, ...userInfo } = user;
const { password: _password, otpSecretKey: _otpSecretKey, ...userInfo } = user;
return userInfo;
}

export function buildFoundUserInfoWithoutCompanyRO(user: UserEntity): FoundUserInfoWithoutCompanyRO {
const { password: _password, company: _company, ...userInfo } = user;
const { password: _password, company: _company, otpSecretKey: _otpSecretKey, ...userInfo } = user;
return userInfo;
}
Comment on lines 4 to 12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Switch these user mappers to an allowlist DTO.

Lines 5 and 10 still return “everything except known secrets”. That means any future sensitive UserEntity field will leak by default until every redaction site is updated, and the declared return types will not reliably catch that drift. Build the response from an explicit allowlist (or a single shared mapper that does) so this layer fails closed instead of open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/microservices/saas-microservice/utils/build-found-user-info-ro.ts`
around lines 4 - 12, The mappers buildFoundUserInfoRO and
buildFoundUserInfoWithoutCompanyRO currently return "all except secrets" which
is fragile; change each to construct the return object from an explicit
allowlist of fields (or replace both with a single shared mapper) by explicitly
picking and assigning only the permitted properties from UserEntity into the
FoundUserInfoRO and FoundUserInfoWithoutCompanyRO shapes (e.g., id, email, name,
roles, createdAt, etc.), ensuring the object literal matches the declared return
types and omits any sensitive fields like password, otpSecretKey, company where
appropriate so new UserEntity fields won't leak by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import test from 'ava';
import { isReadOnlyMongoAggregationPipeline } from '../../../src/ai-core/tools/query-validators.js';

test('allows a plain read-only pipeline', (t) => {
t.true(isReadOnlyMongoAggregationPipeline('[{"$match":{"status":"active"}},{"$group":{"_id":"$type"}}]'));
});

test('allows a $lookup read pipeline', (t) => {
t.true(
isReadOnlyMongoAggregationPipeline(
'[{"$lookup":{"from":"orders","localField":"id","foreignField":"user_id","as":"o"}},{"$unwind":"$o"}]',
),
);
});

test('rejects $out (collection overwrite)', (t) => {
t.false(isReadOnlyMongoAggregationPipeline('[{"$match":{}},{"$limit":0},{"$out":"users"}]'));
});

test('rejects $merge (collection write)', (t) => {
t.false(isReadOnlyMongoAggregationPipeline('[{"$merge":{"into":"users","whenMatched":"replace"}}]'));
});

test('rejects $where (server-side JS)', (t) => {
t.false(isReadOnlyMongoAggregationPipeline('[{"$match":{"$where":"sleep(10000) || true"}}]'));
});

test('rejects $function (server-side JS)', (t) => {
t.false(
isReadOnlyMongoAggregationPipeline(
'[{"$addFields":{"x":{"$function":{"body":"function(){return 1;}","args":[],"lang":"js"}}}}]',
),
);
});

test('rejects $accumulator (server-side JS)', (t) => {
t.false(
isReadOnlyMongoAggregationPipeline(
'[{"$group":{"_id":"$k","v":{"$accumulator":{"init":"function(){return 0}","accumulate":"function(){}","accumulateArgs":[],"merge":"function(){}","lang":"js"}}}}]',
),
);
});

test('rejects a write stage nested inside a $lookup sub-pipeline', (t) => {
t.false(
isReadOnlyMongoAggregationPipeline('[{"$lookup":{"from":"orders","as":"o","pipeline":[{"$out":"stolen"}]}}]'),
);
});

test('returns false (fail-closed) for an unparseable pipeline', (t) => {
t.false(isReadOnlyMongoAggregationPipeline('not valid json {'));
});
Comment on lines +50 to +52
Loading