fix(mongodb): validate options and bound findOne queries

This commit is contained in:
Võ Văn Thành (Jay) authored and GitHub committed 2026-07-15 13:19:53 +08:00
1 parent d098e3e970
commit 55f15c8e4b
14 files changed
+934 -5

No files matched your search

+4
View File
@@ -434,6 +434,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase");
export const mongoDropCollection = forward("mongoDropCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoFindOne = forward("mongoFindOne");
export const mongoCountDocuments = forward("mongoCountDocuments");
export const mongoServerVersion = forward("mongoServerVersion");
export const mongoAggregateDocuments = forward("mongoAggregateDocuments");
@@ -449,6 +450,9 @@ export const mongoUpdateDocuments = forward("mongoUpdateDocuments");
export const documentDeleteDocument = forward("documentDeleteDocument");
export const mongoDeleteDocument = forward("mongoDeleteDocument");
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
export const mongoFindOneAndReplace = forward("mongoFindOneAndReplace");
export const mongoFindOneAndDelete = forward("mongoFindOneAndDelete");
// Elasticsearch
export const elasticsearchListIndices = forward("elasticsearchListIndices");
+16
View File
@@ -2015,6 +2015,10 @@ export async function mongoFindDocuments(connectionId: string, database: string,
return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId);
}
export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
@@ -2128,6 +2132,18 @@ export async function mongoDeleteDocuments(connectionId: string, database: strin
return post("/api/mongo/delete-documents", { connectionId, database, collection, filterJson, many });
}
export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/find-one-and-update", { connectionId, database, collection, filterJson, updateJson, optionsJson });
}
export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/find-one-and-replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson });
}
export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/find-one-and-delete", { connectionId, database, collection, filterJson, optionsJson });
}
// ---------------------------------------------------------------------------
// History
// ---------------------------------------------------------------------------
+16
View File
@@ -1804,6 +1804,10 @@ export async function mongoFindDocuments(connectionId: string, database: string,
return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId);
}
export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise<MongoDocumentResult> {
return invoke("mongo_find_one", { connectionId, database, collection, filter, projection, options, executionId });
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
@@ -1921,6 +1925,18 @@ export async function mongoDeleteDocuments(connectionId: string, database: strin
return { affected_rows: affectedRows };
}
export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return invoke("mongo_find_one_and_update", { connectionId, database, collection, filterJson, updateJson, optionsJson });
}
export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return invoke("mongo_find_one_and_replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson });
}
export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise<MongoDocumentResult> {
return invoke("mongo_find_one_and_delete", { connectionId, database, collection, filterJson, optionsJson });
}
// --- History ---
export interface HistoryEntry {
id: string;
@@ -38,6 +38,9 @@ const COLLECTION_METHODS = [
{ label: "updateMany", detail: "Update all matching documents", apply: "updateMany({}, { $set: {} })" },
{ label: "deleteOne", detail: "Delete one matching document", apply: "deleteOne({})" },
{ label: "deleteMany", detail: "Delete all matching documents", apply: "deleteMany({})" },
{ label: "findOneAndUpdate", detail: "Atomically update and return a document", apply: "findOneAndUpdate({}, { $set: {} })" },
{ label: "findOneAndReplace", detail: "Atomically replace and return a document", apply: "findOneAndReplace({}, {})" },
{ label: "findOneAndDelete", detail: "Atomically delete and return a document", apply: "findOneAndDelete({})" },
{ label: "getIndexes", detail: "List collection indexes", apply: "getIndexes()" },
{ label: "stats", detail: "Show collection statistics", apply: "stats()" },
{ label: "dataSize", detail: "Total size of documents in bytes", apply: "dataSize()" },
@@ -72,7 +75,7 @@ const FIELD_SNIPPETS = [
const QUERY_OPERATORS = ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists", "$regex", "$and", "$or", "$nor", "$not", "$elemMatch"];
const UPDATE_OPERATORS = ["$set", "$unset", "$inc", "$push", "$pull", "$addToSet", "$rename", "$currentDate", "$setOnInsert"];
const PIPELINE_STAGES = ["$match", "$project", "$group", "$sort", "$limit", "$skip", "$unwind", "$lookup", "$addFields", "$count", "$facet"];
const QUERY_METHODS = ["find", "findOne", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "dropIndex", "dropIndexes", "sort"];
const QUERY_METHODS = ["find", "findOne", "findOneAndUpdate", "findOneAndReplace", "findOneAndDelete", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "dropIndex", "dropIndexes", "sort"];
export function getMongoCompletionContext(text: string, cursor: number): MongoCompletionContext {
const safeCursor = Math.max(0, Math.min(cursor, text.length));
@@ -365,7 +368,7 @@ function mongoFieldApplyText(field: string, prefix: string): string {
}
function findInnermostMongoCall(beforeCursor: string): { method: string; openParenIndex: number } | null {
const callPattern = /\.(find|findOne|countDocuments|updateOne|updateMany|deleteOne|deleteMany|aggregate|sort)\s*\(/g;
const callPattern = /\.(find|findOne|findOneAndUpdate|findOneAndReplace|findOneAndDelete|countDocuments|updateOne|updateMany|deleteOne|deleteMany|aggregate|sort)\s*\(/g;
let match: RegExpExecArray | null;
let result: { method: string; openParenIndex: number } | null = null;
while ((match = callPattern.exec(beforeCursor))) {
+127 -3
View File
@@ -10,6 +10,13 @@ export interface MongoFindCommand {
sort?: string;
}
export interface MongoFindOneCommand {
collection: string;
filter: string;
projection?: string;
options?: string;
}
export interface MongoCountDocumentsCommand {
collection: string;
filter: string;
@@ -41,10 +48,11 @@ export interface MongoCollectionStatsCommand {
scale?: number;
}
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes" | "dropCollection";
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
export type MongoCommand =
| ({ kind: "find" } & MongoFindCommand)
| ({ kind: "findOne" } & MongoFindOneCommand)
| MongoVersionCommand
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
| ({ kind: "aggregate" } & MongoAggregateCommand)
@@ -57,7 +65,10 @@ export type MongoCommand =
| { kind: "createIndex"; collection: string; keys: string; options?: string }
| { kind: "dropIndex"; collection: string; index: string }
| { kind: "dropIndexes"; collection: string; indexes?: string }
| { kind: "dropCollection"; collection: string };
| { kind: "dropCollection"; collection: string }
| { kind: "findOneAndUpdate"; collection: string; filter: string; update: string; options?: string }
| { kind: "findOneAndReplace"; collection: string; filter: string; replacement: string; options?: string }
| { kind: "findOneAndDelete"; collection: string; filter: string; options?: string };
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
@@ -124,6 +135,103 @@ export function parseMongoFindCommand(input: string): MongoFindCommand | null {
};
}
export function parseMongoFindOneCommand(input: string): MongoFindOneCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "findOne");
if (!target) return null;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args) return null;
if (args.length > 3 && args.slice(3).some((arg) => arg.trim())) return null;
const filter = normalizeJsonArgument(args[0] || "{}");
if (!filter) return null;
let projection: string | undefined;
if (args[1]?.trim()) {
const parsedProjection = normalizeJsonArgument(args[1]);
if (!parsedProjection) return null;
projection = parsedProjection;
}
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
if (args[2]?.trim() && !options) return null;
return {
collection: target.collection,
filter,
...(projection ? { projection } : {}),
...(options ? { options } : {}),
};
}
export interface MongoFindOneAndUpdateCommand {
collection: string;
filter: string;
update: string;
options?: string;
}
export interface MongoFindOneAndReplaceCommand {
collection: string;
filter: string;
replacement: string;
options?: string;
}
export interface MongoFindOneAndDeleteCommand {
collection: string;
filter: string;
options?: string;
}
export function parseMongoFindOneAndUpdateCommand(input: string): MongoFindOneAndUpdateCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "findOneAndUpdate");
if (!target) return null;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length < 2 || args.length > 3) return null;
const filter = normalizeJsonArgument(args[0] || "{}");
const update = normalizeJsonArgument(args[1]);
if (!filter || !update) return null;
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
if (args[2]?.trim() && !options) return null;
return { collection: target.collection, filter, update, ...(options ? { options } : {}) };
}
export function parseMongoFindOneAndReplaceCommand(input: string): MongoFindOneAndReplaceCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "findOneAndReplace");
if (!target) return null;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length < 2 || args.length > 3) return null;
const filter = normalizeJsonArgument(args[0] || "{}");
const replacement = normalizeJsonArgument(args[1]);
if (!filter || !replacement) return null;
const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined;
if (args[2]?.trim() && !options) return null;
return { collection: target.collection, filter, replacement, ...(options ? { options } : {}) };
}
export function parseMongoFindOneAndDeleteCommand(input: string): MongoFindOneAndDeleteCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "findOneAndDelete");
if (!target) return null;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length < 1 || args.length > 2) return null;
const filter = normalizeJsonArgument(args[0] || "{}");
if (!filter) return null;
const options = args[1]?.trim() ? normalizeJsonArgument(args[1]) : undefined;
if (args[1]?.trim() && !options) return null;
return { collection: target.collection, filter, ...(options ? { options } : {}) };
}
export function applyMongoFindSort(input: string, column: string, direction: "asc" | "desc"): string | null {
const source = input.trim().replace(/;$/, "").trim();
const parsed = parseMongoFindCommand(source);
@@ -376,6 +484,22 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
const find = parseMongoFindCommand(source);
return find ? { kind: "find", ...find } : null;
},
(source) => {
const findOne = parseMongoFindOneCommand(source);
return findOne ? { kind: "findOne", ...findOne } : null;
},
(source) => {
const findOneAndUpdate = parseMongoFindOneAndUpdateCommand(source);
return findOneAndUpdate ? { kind: "findOneAndUpdate", ...findOneAndUpdate } : null;
},
(source) => {
const findOneAndReplace = parseMongoFindOneAndReplaceCommand(source);
return findOneAndReplace ? { kind: "findOneAndReplace", ...findOneAndReplace } : null;
},
(source) => {
const findOneAndDelete = parseMongoFindOneAndDeleteCommand(source);
return findOneAndDelete ? { kind: "findOneAndDelete", ...findOneAndDelete } : null;
},
(source) => {
const aggregate = parseMongoAggregateCommand(source);
return aggregate ? { kind: "aggregate", ...aggregate } : null;
@@ -427,7 +551,7 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: Mo
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
};
}
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) {
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete" || command.kind === "findOneAndUpdate" || command.kind === "findOneAndReplace" || command.kind === "findOneAndDelete") && isEmptyJsonObject(command.filter)) {
return {
allowed: false,
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
+46
View File
@@ -2782,6 +2782,21 @@ export const useQueryStore = defineStore("query", () => {
});
break;
}
case "findOne": {
console.info("[DBX][executeTabSql:mongo-find-one:start]", { traceId, collection: mongoCommand.collection, database: currentDatabase });
const result = await api.mongoFindOne(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId);
const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)));
allResults.push(queryResult);
mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined;
console.info("[DBX][executeTabSql:mongo-find-one:done]", {
traceId,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "version": {
console.info("[DBX][executeTabSql:mongo-version:start]", { traceId, database: currentDatabase });
const version = await api.mongoServerVersion(tab.connectionId, currentDatabase, executionId);
@@ -2862,6 +2877,37 @@ export const useQueryStore = defineStore("query", () => {
});
break;
}
case "findOneAndUpdate":
case "findOneAndReplace":
case "findOneAndDelete": {
if (options?.mongoSafety) {
const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety);
if (!safety.allowed) throw new Error(safety.reason);
}
console.info("[DBX][executeTabSql:mongo-find-and-modify:start]", {
traceId,
kind: mongoCommand.kind,
collection: mongoCommand.collection,
database: currentDatabase,
});
const result =
mongoCommand.kind === "findOneAndUpdate"
? await api.mongoFindOneAndUpdate(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options)
: mongoCommand.kind === "findOneAndReplace"
? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options)
: await api.mongoFindOneAndDelete(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))));
mongoEditTarget = undefined;
console.info("[DBX][executeTabSql:mongo-find-and-modify:done]", {
traceId,
kind: mongoCommand.kind,
collection: mongoCommand.collection,
database: currentDatabase,
rowCount: result.documents.length,
elapsed: elapsed(),
});
break;
}
case "insert":
case "update":
case "delete":