@@ -62,6 +62,44 @@ const COLLECTION_OPERATIONS = [
6262// Define write operations that are blocked in read-only mode
6363const 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
66104type 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