Skip to content

Commit 98550c6

Browse files
fix(NODE-5496): remove client-side collection and database name check validation (#3873)
1 parent 33933ba commit 98550c6

File tree

12 files changed

+79
-248
lines changed

12 files changed

+79
-248
lines changed

src/cmap/command_monitoring_events.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
LEGACY_HELLO_COMMAND_CAMEL_CASE
88
} from '../constants';
99
import { calculateDurationInMs, deepCopy } from '../utils';
10-
import { Msg, type WriteProtocolMessageType } from './commands';
10+
import { Msg, type Query, type WriteProtocolMessageType } from './commands';
1111
import type { Connection } from './connection';
1212

1313
/**
@@ -49,7 +49,7 @@ export class CommandStartedEvent {
4949
this.connectionId = connectionId;
5050
this.serviceId = serviceId;
5151
this.requestId = command.requestId;
52-
this.databaseName = databaseName(command);
52+
this.databaseName = command.databaseName;
5353
this.commandName = commandName;
5454
this.command = maybeRedact(commandName, cmd, cmd);
5555
}
@@ -181,9 +181,8 @@ const HELLO_COMMANDS = new Set(['hello', LEGACY_HELLO_COMMAND, LEGACY_HELLO_COMM
181181

182182
// helper methods
183183
const extractCommandName = (commandDoc: Document) => Object.keys(commandDoc)[0];
184-
const namespace = (command: WriteProtocolMessageType) => command.ns;
185-
const databaseName = (command: WriteProtocolMessageType) => command.ns.split('.')[0];
186-
const collectionName = (command: WriteProtocolMessageType) => command.ns.split('.')[1];
184+
const namespace = (command: Query) => command.ns;
185+
const collectionName = (command: Query) => command.ns.split('.')[1];
187186
const maybeRedact = (commandName: string, commandDoc: Document, result: Error | Document) =>
188187
SENSITIVE_COMMANDS.has(commandName) ||
189188
(HELLO_COMMANDS.has(commandName) && commandDoc.speculativeAuthenticate)

src/cmap/commands.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as BSON from '../bson';
33
import { MongoInvalidArgumentError, MongoRuntimeError } from '../error';
44
import { ReadPreference } from '../read_preference';
55
import type { ClientSession } from '../sessions';
6-
import { databaseNamespace } from '../utils';
76
import type { CommandOptions } from './connection';
87
import { OP_MSG, OP_QUERY } from './wire_protocol/constants';
98

@@ -55,7 +54,6 @@ export interface OpQueryOptions extends CommandOptions {
5554
/** @internal */
5655
export class Query {
5756
ns: string;
58-
query: Document;
5957
numberToSkip: number;
6058
numberToReturn: number;
6159
returnFieldSelector?: Document;
@@ -75,10 +73,13 @@ export class Query {
7573
partial: boolean;
7674
documentsReturnedIn?: string;
7775

78-
constructor(ns: string, query: Document, options: OpQueryOptions) {
76+
constructor(public databaseName: string, public query: Document, options: OpQueryOptions) {
7977
// Basic options needed to be passed in
8078
// TODO(NODE-3483): Replace with MongoCommandError
81-
if (ns == null) throw new MongoRuntimeError('Namespace must be specified for query');
79+
const ns = `${databaseName}.$cmd`;
80+
if (typeof databaseName !== 'string') {
81+
throw new MongoRuntimeError('Database name must be a string for a query');
82+
}
8283
// TODO(NODE-3483): Replace with MongoCommandError
8384
if (query == null) throw new MongoRuntimeError('A query document must be specified for query');
8485

@@ -90,7 +91,6 @@ export class Query {
9091

9192
// Basic options
9293
this.ns = ns;
93-
this.query = query;
9494

9595
// Additional options
9696
this.numberToSkip = options.numberToSkip || 0;
@@ -473,9 +473,6 @@ export interface OpMsgOptions {
473473

474474
/** @internal */
475475
export class Msg {
476-
ns: string;
477-
command: Document;
478-
options: OpQueryOptions;
479476
requestId: number;
480477
serializeFunctions: boolean;
481478
ignoreUndefined: boolean;
@@ -485,15 +482,17 @@ export class Msg {
485482
moreToCome: boolean;
486483
exhaustAllowed: boolean;
487484

488-
constructor(ns: string, command: Document, options: OpQueryOptions) {
485+
constructor(
486+
public databaseName: string,
487+
public command: Document,
488+
public options: OpQueryOptions
489+
) {
489490
// Basic options needed to be passed in
490491
if (command == null)
491492
throw new MongoInvalidArgumentError('Query document must be specified for query');
492493

493494
// Basic options
494-
this.ns = ns;
495-
this.command = command;
496-
this.command.$db = databaseNamespace(ns);
495+
this.command.$db = databaseName;
497496

498497
if (options.readPreference && options.readPreference.mode !== ReadPreference.PRIMARY) {
499498
this.command.$readPreference = options.readPreference.toJSON();

src/cmap/connection.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -539,10 +539,9 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
539539
options
540540
);
541541

542-
const cmdNs = `${ns.db}.$cmd`;
543542
const message = shouldUseOpMsg
544-
? new Msg(cmdNs, cmd, commandOptions)
545-
: new Query(cmdNs, cmd, commandOptions);
543+
? new Msg(ns.db, cmd, commandOptions)
544+
: new Query(ns.db, cmd, commandOptions);
546545

547546
try {
548547
write(this, message, commandOptions, callback);

src/collection.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ import {
9090
import { ReadConcern, type ReadConcernLike } from './read_concern';
9191
import { ReadPreference, type ReadPreferenceLike } from './read_preference';
9292
import {
93-
checkCollectionName,
9493
DEFAULT_PK_FACTORY,
9594
MongoDBCollectionNamespace,
9695
normalizeHintField,
@@ -164,8 +163,6 @@ export class Collection<TSchema extends Document = Document> {
164163
* @internal
165164
*/
166165
constructor(db: Db, name: string, options?: CollectionOptions) {
167-
checkCollectionName(name);
168-
169166
// Internal state
170167
this.s = {
171168
db,

src/db.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as CONSTANTS from './constants';
66
import { AggregationCursor } from './cursor/aggregation_cursor';
77
import { ListCollectionsCursor } from './cursor/list_collections_cursor';
88
import { RunCommandCursor, type RunCursorCommandOptions } from './cursor/run_command_cursor';
9-
import { MongoAPIError, MongoInvalidArgumentError } from './error';
9+
import { MongoInvalidArgumentError } from './error';
1010
import type { MongoClient, PkFactory } from './mongo_client';
1111
import type { TODO_NODE_3286 } from './mongo_types';
1212
import type { AggregateOptions } from './operations/aggregate';
@@ -134,20 +134,24 @@ export class Db {
134134
public static SYSTEM_JS_COLLECTION = CONSTANTS.SYSTEM_JS_COLLECTION;
135135

136136
/**
137-
* Creates a new Db instance
137+
* Creates a new Db instance.
138+
*
139+
* Db name cannot contain a dot, the server may apply more restrictions when an operation is run.
138140
*
139141
* @param client - The MongoClient for the database.
140142
* @param databaseName - The name of the database this instance represents.
141-
* @param options - Optional settings for Db construction
143+
* @param options - Optional settings for Db construction.
142144
*/
143145
constructor(client: MongoClient, databaseName: string, options?: DbOptions) {
144146
options = options ?? {};
145147

146148
// Filter the options
147149
options = filterOptions(options, DB_OPTIONS_ALLOW_LIST);
148150

149-
// Ensure we have a valid db name
150-
validateDatabaseName(databaseName);
151+
// Ensure there are no dots in database name
152+
if (typeof databaseName === 'string' && databaseName.includes('.')) {
153+
throw new MongoInvalidArgumentError(`Database names cannot contain the character '.'`);
154+
}
151155

152156
// Internal state of the db object
153157
this.s = {
@@ -218,6 +222,8 @@ export class Db {
218222
* Create a new collection on a server with the specified options. Use this to create capped collections.
219223
* More information about command options available at https://www.mongodb.com/docs/manual/reference/command/create/
220224
*
225+
* Collection namespace validation is performed server-side.
226+
*
221227
* @param name - The name of the collection to create
222228
* @param options - Optional settings for the command
223229
*/
@@ -294,6 +300,8 @@ export class Db {
294300
/**
295301
* Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly.
296302
*
303+
* Collection namespace validation is performed server-side.
304+
*
297305
* @param name - the collection name we wish to access.
298306
* @returns return the new Collection instance
299307
*/
@@ -519,19 +527,3 @@ export class Db {
519527
return new RunCommandCursor(this, command, options);
520528
}
521529
}
522-
523-
// TODO(NODE-3484): Refactor into MongoDBNamespace
524-
// Validate the database name
525-
function validateDatabaseName(databaseName: string) {
526-
if (typeof databaseName !== 'string')
527-
throw new MongoInvalidArgumentError('Database name must be a string');
528-
if (databaseName.length === 0)
529-
throw new MongoInvalidArgumentError('Database name cannot be the empty string');
530-
if (databaseName === '$external') return;
531-
532-
const invalidChars = [' ', '.', '$', '/', '\\'];
533-
for (let i = 0; i < invalidChars.length; i++) {
534-
if (databaseName.indexOf(invalidChars[i]) !== -1)
535-
throw new MongoAPIError(`database names cannot contain the character '${invalidChars[i]}'`);
536-
}
537-
}

src/operations/rename.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Document } from '../bson';
22
import { Collection } from '../collection';
33
import type { Server } from '../sdam/server';
44
import type { ClientSession } from '../sessions';
5-
import { checkCollectionName, MongoDBNamespace } from '../utils';
5+
import { MongoDBNamespace } from '../utils';
66
import { CommandOperation, type CommandOperationOptions } from './command';
77
import { Aspect, defineAspects } from './operation';
88

@@ -21,7 +21,6 @@ export class RenameOperation extends CommandOperation<Document> {
2121
public newName: string,
2222
public override options: RenameOptions
2323
) {
24-
checkCollectionName(newName);
2524
super(collection, options);
2625
this.ns = new MongoDBNamespace('admin', '$cmd');
2726
}

src/utils.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -78,39 +78,6 @@ export function hostMatchesWildcards(host: string, wildcards: string[]): boolean
7878
return false;
7979
}
8080

81-
/**
82-
* Throws if collectionName is not a valid mongodb collection namespace.
83-
* @internal
84-
*/
85-
export function checkCollectionName(collectionName: string): void {
86-
if ('string' !== typeof collectionName) {
87-
throw new MongoInvalidArgumentError('Collection name must be a String');
88-
}
89-
90-
if (!collectionName || collectionName.indexOf('..') !== -1) {
91-
throw new MongoInvalidArgumentError('Collection names cannot be empty');
92-
}
93-
94-
if (
95-
collectionName.indexOf('$') !== -1 &&
96-
collectionName.match(/((^\$cmd)|(oplog\.\$main))/) == null
97-
) {
98-
// TODO(NODE-3483): Use MongoNamespace static method
99-
throw new MongoInvalidArgumentError("Collection names must not contain '$'");
100-
}
101-
102-
if (collectionName.match(/^\.|\.$/) != null) {
103-
// TODO(NODE-3483): Use MongoNamespace static method
104-
throw new MongoInvalidArgumentError("Collection names must not start or end with '.'");
105-
}
106-
107-
// Validate that we are not passing 0x00 in the collection name
108-
if (collectionName.indexOf('\x00') !== -1) {
109-
// TODO(NODE-3483): Use MongoNamespace static method
110-
throw new MongoInvalidArgumentError('Collection names cannot contain a null character');
111-
}
112-
}
113-
11481
/**
11582
* Ensure Hint field is in a shape we expect:
11683
* - object of index names mapping to 1 or -1
@@ -386,11 +353,6 @@ export function maybeCallback<T>(
386353
return;
387354
}
388355

389-
/** @internal */
390-
export function databaseNamespace(ns: string): string {
391-
return ns.split('.')[0];
392-
}
393-
394356
/**
395357
* Synchronously Generate a UUIDv4
396358
* @internal

test/integration/collection-management/collection.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai';
22

3-
import { Collection, type Db, isHello, type MongoClient } from '../../mongodb';
3+
import { Collection, type Db, isHello, type MongoClient, MongoServerError } from '../../mongodb';
44
import * as mock from '../../tools/mongodb-mock/index';
55
import { setupDatabase } from '../shared';
66

@@ -95,18 +95,14 @@ describe('Collection', function () {
9595
]);
9696
});
9797

98-
it('should fail due to illegal listCollections', function (done) {
99-
expect(() => db.collection(5)).to.throw('Collection name must be a String');
100-
expect(() => db.collection('')).to.throw('Collection names cannot be empty');
101-
expect(() => db.collection('te$t')).to.throw("Collection names must not contain '$'");
102-
expect(() => db.collection('.test')).to.throw(
103-
"Collection names must not start or end with '.'"
104-
);
105-
expect(() => db.collection('test.')).to.throw(
106-
"Collection names must not start or end with '.'"
107-
);
108-
expect(() => db.collection('test..t')).to.throw('Collection names cannot be empty');
109-
done();
98+
it('fails on server due to invalid namespace', async function () {
99+
const error = await db
100+
.collection('a\x00b')
101+
.insertOne({ a: 1 })
102+
.catch(error => error);
103+
expect(error).to.be.instanceOf(MongoServerError);
104+
expect(error).to.have.property('code', 73);
105+
expect(error).to.have.property('codeName', 'InvalidNamespace');
110106
});
111107

112108
it('should correctly count on non-existent collection', function (done) {

test/integration/node-specific/db.test.js

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,47 @@
22

33
const { setupDatabase, assert: test } = require(`../shared`);
44
const { expect } = require('chai');
5-
const { Db, MongoClient } = require('../../mongodb');
5+
const { MongoClient, MongoInvalidArgumentError, MongoServerError } = require('../../mongodb');
66

77
describe('Db', function () {
88
before(function () {
99
return setupDatabase(this.configuration);
1010
});
1111

12-
it('shouldCorrectlyHandleIllegalDbNames', {
13-
metadata: {
14-
requires: { topology: ['single', 'replicaset', 'sharded'] }
15-
},
12+
context('when given illegal db name', function () {
13+
let client;
14+
let db;
15+
16+
beforeEach(function () {
17+
client = this.configuration.newClient();
18+
});
19+
20+
afterEach(async function () {
21+
db = undefined;
22+
await client.close();
23+
});
24+
25+
context('of type string, containing no dot characters', function () {
26+
it('should throw error on server only', async function () {
27+
db = client.db('a\x00b');
28+
const error = await db.createCollection('spider').catch(error => error);
29+
expect(error).to.be.instanceOf(MongoServerError);
30+
expect(error).to.have.property('code', 73);
31+
expect(error).to.have.property('codeName', 'InvalidNamespace');
32+
});
33+
});
1634

17-
test: done => {
18-
const client = { bsonOptions: {} };
19-
expect(() => new Db(client, 5)).to.throw('Database name must be a string');
20-
expect(() => new Db(client, '')).to.throw('Database name cannot be the empty string');
21-
expect(() => new Db(client, 'te$t')).to.throw(
22-
"database names cannot contain the character '$'"
23-
);
24-
expect(() => new Db(client, '.test', function () {})).to.throw(
25-
"database names cannot contain the character '.'"
26-
);
27-
expect(() => new Db(client, '\\test', function () {})).to.throw(
28-
"database names cannot contain the character '\\'"
29-
);
30-
expect(() => new Db(client, 'test test', function () {})).to.throw(
31-
"database names cannot contain the character ' '"
32-
);
33-
done();
34-
}
35+
context('of type string, containing a dot character', function () {
36+
it('should throw MongoInvalidArgumentError', function () {
37+
expect(() => client.db('a.b')).to.throw(MongoInvalidArgumentError);
38+
});
39+
});
40+
41+
context('of type non-string type', function () {
42+
it('should not throw client-side', function () {
43+
expect(() => client.db(5)).to.not.throw();
44+
});
45+
});
3546
});
3647

3748
it('shouldCorrectlyHandleFailedConnection', {

0 commit comments

Comments
 (0)