Skip to content

Commit e3a365c

Browse files
committed
fix: enforce read-only mode for aggregation write and JS operators
Read-only mode previously guarded only insert/update/createIndex, so an aggregate pipeline ending in $out or $merge could still create or replace collections, bypassing the protection. handleAggregate now receives the read-only flag and recursively rejects $out, $merge, $function, $accumulator and $where when read-only is enabled. Normal (writable) mode is unchanged.
1 parent 5aa37ee commit e3a365c

1 file changed

Lines changed: 58 additions & 1 deletion

File tree

src/schemas/call.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,44 @@ const COLLECTION_OPERATIONS = [
6262
// Define write operations that are blocked in read-only mode
6363
const WRITE_OPERATIONS = ["update", "insert", "createIndex"];
6464

65+
// Aggregation stages/operators that must never run in read-only mode:
66+
// write stages ($out, $merge) can create or replace collections, and
67+
// server-side JavaScript operators ($function, $accumulator, $where) allow
68+
// arbitrary code execution. All are matched case-sensitively as object keys.
69+
const READONLY_FORBIDDEN_AGG_OPERATORS = new Set([
70+
"$out",
71+
"$merge",
72+
"$function",
73+
"$accumulator",
74+
"$where",
75+
]);
76+
77+
/**
78+
* Recursively scan an aggregation pipeline for operators that are forbidden in
79+
* read-only mode. Returns the first forbidden operator found, or null.
80+
*/
81+
function findForbiddenAggOperator(value: unknown): string | null {
82+
if (Array.isArray(value)) {
83+
for (const item of value) {
84+
const found = findForbiddenAggOperator(item);
85+
if (found) return found;
86+
}
87+
return null;
88+
}
89+
90+
if (value && typeof value === "object") {
91+
for (const [key, nested] of Object.entries(value)) {
92+
if (READONLY_FORBIDDEN_AGG_OPERATORS.has(key)) {
93+
return key;
94+
}
95+
const found = findForbiddenAggOperator(nested);
96+
if (found) return found;
97+
}
98+
}
99+
100+
return null;
101+
}
102+
65103
// ObjectId conversion settings
66104
type ObjectIdConversionMode = "auto" | "none" | "force";
67105

@@ -203,7 +241,13 @@ async function executeOperation(
203241
case "query":
204242
return handleQuery(collection, args, objectIdMode, signal);
205243
case "aggregate":
206-
return handleAggregate(collection, args, objectIdMode, signal);
244+
return handleAggregate(
245+
collection,
246+
args,
247+
objectIdMode,
248+
isReadOnlyMode,
249+
signal,
250+
);
207251
case "update":
208252
return handleUpdate(collection, args, objectIdMode, signal);
209253
case "serverInfo":
@@ -553,6 +597,7 @@ async function handleAggregate(
553597
collection: Collection<Document> | null,
554598
args: Record<string, unknown>,
555599
objectIdMode: ObjectIdConversionMode = "auto",
600+
isReadOnlyMode = false,
556601
signal?: AbortSignal,
557602
) {
558603
if (!collection) {
@@ -564,6 +609,18 @@ async function handleAggregate(
564609
throw new Error("Pipeline must be an array");
565610
}
566611

612+
// In read-only mode, reject pipelines that could write to the database or
613+
// execute server-side JavaScript. Without this an aggregate ending in $out
614+
// or $merge would bypass read-only protection and overwrite collections.
615+
if (isReadOnlyMode) {
616+
const forbidden = findForbiddenAggOperator(pipeline);
617+
if (forbidden) {
618+
throw new Error(
619+
`ReadonlyError: Aggregation operator '${forbidden}' is not allowed in read-only mode`,
620+
);
621+
}
622+
}
623+
567624
// Process any ObjectId strings in the pipeline
568625
const processedPipeline = pipeline.map((stage) => {
569626
if (typeof stage === "object" && stage !== null) {

0 commit comments

Comments
 (0)