Skip to content

Commit a0cfc4d

Browse files
committed
fix: harden write path policy escapes
1 parent 9ab2637 commit a0cfc4d

7 files changed

Lines changed: 184 additions & 33 deletions

File tree

changelogs/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- Added a short-lived read-cache dirty barrier around writes and transaction commits. Cached reads now bypass and avoid refilling query cache while a namespace is being invalidated, reducing stale-cache windows when a process exits between a database write and post-write invalidation.
1919
- Added optional Change Stream sync idempotency gates (`sync.idempotency`) with per-target keys and duplicate stats, so supervised restarts can skip targets already marked as applied before saving the shared resume token.
2020
- Added `writePathPolicy` with default `allow-both` behavior and optional `model-only` namespace enforcement across collection, db, legacy, raw, management, batch, and aggregate `$out` / `$merge` write paths.
21+
- Hardened `writePathPolicy` guard coverage for native client access, legacy `dropDatabase`, management-operation target namespaces, and instance-scoped database-level rules.
2122
- Added strict optimistic-locking support to Model `updateBatch(..., { versionMode: 'strict' })`; default `counter` behavior remains unchanged.
2223
- Clarified the runtime consistency contract across cache, transactions, Change Stream sync, and CountQueue; `transaction.distributedLock` now warns as a v1 compatibility placeholder because v2 transaction cache locks remain process-local.
2324
- Added an event-level barrier for Change Stream sync target failures, passed a cooperative `AbortSignal` through `CountQueue.execute()` timeouts, and unified ObjectId auto-conversion field matching across query/write paths including nested array path segments.

src/adapters/mongodb/common/collection-accessor.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,13 +612,13 @@ export class MongoCollectionAccessor<TSchema extends Document = Document> {
612612

613613
/** Creates a collection (or a named alternative) with the given options. */
614614
async createCollection(name?: string, options: Record<string, unknown> = {}): Promise<boolean> {
615-
this.assertWritePath('createCollection', 'management');
615+
this.assertManagementWriteTargets('createCollection', name);
616616
return createCollectionForAccessor(this.collectionRef, this.collectionName, this.dbRef, name, options);
617617
}
618618

619619
/** Creates a MongoDB view backed by the given source collection and aggregation pipeline. */
620620
async createView(name: string, source: string, pipeline: unknown[] = []): Promise<boolean> {
621-
this.assertWritePath('createView', 'management');
621+
this.assertManagementWriteTargets('createView', name);
622622
return createViewForAccessor(this.collectionRef, this.dbRef, name, source, pipeline);
623623
}
624624

@@ -655,7 +655,7 @@ export class MongoCollectionAccessor<TSchema extends Document = Document> {
655655

656656
/** Renames the collection, optionally dropping an existing target collection. */
657657
async renameCollection(newName: unknown, options: { dropTarget?: boolean } = {}): Promise<{ renamed: boolean; from: string; to: string }> {
658-
this.assertWritePath('renameCollection', 'management');
658+
this.assertManagementWriteTargets('renameCollection', newName);
659659
return renameCollectionForAccessor(this.collectionRef, this.collectionName, newName, options);
660660
}
661661

@@ -683,6 +683,21 @@ export class MongoCollectionAccessor<TSchema extends Document = Document> {
683683
});
684684
}
685685

686+
private assertManagementWriteTargets(operation: string, targetCollectionName?: unknown): void {
687+
this.assertWritePath(operation, 'management');
688+
if (
689+
typeof targetCollectionName === 'string'
690+
&& targetCollectionName.length > 0
691+
&& targetCollectionName !== this.collectionName
692+
) {
693+
this.assertWritePath(
694+
operation,
695+
'management',
696+
this.buildNamespaceView(this.dbName, targetCollectionName),
697+
);
698+
}
699+
}
700+
686701
private batchContext(operation: string) {
687702
this.assertWritePath(operation, 'batch');
688703
return {

src/capabilities/write-path-policy/index.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface WritePathPolicyOptions {
2020
}
2121

2222
export type WritePathOperationCategory = 'write' | 'batch' | 'management' | 'raw';
23-
export type WritePathSource = 'collection' | 'model' | 'legacy' | 'db';
23+
export type WritePathSource = 'collection' | 'model' | 'legacy' | 'db' | 'client';
2424

2525
export interface WritePathNamespace {
2626
iid?: string;
@@ -249,12 +249,16 @@ export function assertWritePathAllowed(config: {
249249
}
250250

251251
function namespaceRuleMatchesDb(ruleKey: string, dbName: string): boolean {
252-
if (ruleKey.includes(':')) {
253-
const [, scoped] = ruleKey.split(':', 2);
254-
return scoped?.startsWith(`${dbName}.`) ?? false;
255-
}
256252
if (ruleKey.includes('.')) {
257-
return ruleKey.startsWith(`${dbName}.`);
253+
const scoped = ruleKey.includes(':') ? ruleKey.slice(ruleKey.lastIndexOf(':') + 1) : ruleKey;
254+
return scoped.startsWith(`${dbName}.`);
255+
}
256+
const parts = ruleKey.split(':');
257+
if (parts.length >= 3) {
258+
return parts[parts.length - 2] === dbName;
259+
}
260+
if (parts.length === 2) {
261+
return parts[0] === dbName;
258262
}
259263
return true;
260264
}
@@ -320,6 +324,47 @@ export function assertDbLevelWritePathAllowed(config: {
320324
throw createError(ErrorCodes.INVALID_OPERATION, message, [details]);
321325
}
322326

327+
export function shouldBlockClientLevelWritePath(
328+
policy: NormalizedWritePathPolicy | undefined,
329+
): boolean {
330+
if (!policy) return false;
331+
if (blocksDbLevelCategory(policy.default, 'raw')) {
332+
return true;
333+
}
334+
return Object.values(policy.namespaces).some((rule) => blocksDbLevelCategory(rule, 'raw'));
335+
}
336+
337+
export function assertClientLevelWritePathAllowed(config: {
338+
policy?: NormalizedWritePathPolicy;
339+
operation: string;
340+
logger?: Pick<Logger, 'warn'>;
341+
}): void {
342+
const policy = config.policy;
343+
if (!policy) return;
344+
345+
const matches: Array<{ key: string; rule: NormalizedWritePathRule }> = [
346+
{ key: 'default', rule: policy.default },
347+
...Object.entries(policy.namespaces).map(([key, rule]) => ({ key, rule })),
348+
];
349+
const blocked = matches.find(({ rule }) => blocksDbLevelCategory(rule, 'raw'));
350+
if (!blocked) return;
351+
352+
const details = {
353+
operation: config.operation,
354+
category: 'raw' satisfies WritePathOperationCategory,
355+
source: 'client' satisfies WritePathSource,
356+
namespace: {},
357+
matchedRule: blocked.key,
358+
rule: blocked.rule,
359+
};
360+
const message = buildViolationMessage(config.operation, 'raw', 'client');
361+
if (blocked.rule.onViolation === 'warn') {
362+
config.logger?.warn?.(`[WritePathPolicy] ${message}`, details);
363+
return;
364+
}
365+
throw createError(ErrorCodes.INVALID_OPERATION, message, [details]);
366+
}
367+
323368
export function runWithModelWriteSource<T>(fn: () => T): T {
324369
return modelWriteSourceStorage.run('model', fn);
325370
}

src/entry/runtime-admin-bridge.ts

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ import {
2121
type SlowQueryLogEntry,
2222
} from '../capabilities/slow-query-log';
2323
import { ErrorCodes, createError } from '../core/errors';
24-
import { assertWritePathAllowed, type WritePathOperationCategory } from '../capabilities/write-path-policy';
24+
import {
25+
assertClientLevelWritePathAllowed,
26+
assertWritePathAllowed,
27+
type WritePathOperationCategory,
28+
} from '../capabilities/write-path-policy';
29+
import type { Logger } from '../core/logger';
2530
import type { AdminBuildInfoView, DbStatsView, ServerStatusView } from '../../types/collection';
2631
import type { RuntimeDefaults } from '../types/internal/query';
2732
import type { AdapterBridgeLike, LegacyAdapterBridgeLike } from '../types/internal/runtime';
2833
import type { MonSQLizeOptions } from '../../types/monsqlize';
2934
import type { MongoDbAccessor as DbFacade } from '../adapters/mongodb/common/accessors';
30-
import { isProductionEnvironment } from '../adapters/mongodb/common/drop-database-safety';
3135
import { resolveAggregateWriteTarget } from '../adapters/mongodb/common/collection-accessor-cache-helpers';
3236

3337
/**
@@ -43,6 +47,8 @@ type AdapterBridgeConfig = {
4347
getDb: () => Db | null;
4448
/** Returns the current MongoClient (null when not connected). */
4549
getClient: () => MongoClient | null;
50+
/** Asserts that public native-client access is allowed. */
51+
assertClientAccess: () => void;
4652
/** Returns the current cache instance (may be null). */
4753
getCache: () => CacheLike | null;
4854
/** Replaces the current cache instance. */
@@ -204,7 +210,13 @@ function createAdapterBridge(config: AdapterBridgeConfig): LegacyAdapterBridgeLi
204210
},
205211
client: {
206212
enumerable: true,
207-
get: config.getClient,
213+
get: () => {
214+
const client = config.getClient();
215+
if (client) {
216+
config.assertClientAccess();
217+
}
218+
return client;
219+
},
208220
},
209221
cache: {
210222
enumerable: true,
@@ -280,6 +292,7 @@ export type RuntimeAdapterBridgeHost = {
280292
_client: MongoClient | null;
281293
_iidCache: MemoryCache | null;
282294
_runtimeDefaults: RuntimeDefaults;
295+
_logger: Logger;
283296
_slowQueryLogManager: SlowQueryLogManager | null;
284297
resolveAdapterCache(): CacheLike | null;
285298
setAdapterCache(value: CacheLike | null): void;
@@ -302,6 +315,11 @@ export function createRuntimeAdapterBridge(host: RuntimeAdapterBridgeHost): Lega
302315
return createAdapterBridge({
303316
getDb: () => host._defaultDb?.raw() ?? null,
304317
getClient: () => host._client,
318+
assertClientAccess: () => assertClientLevelWritePathAllowed({
319+
policy: host._runtimeDefaults.writePathPolicy,
320+
operation: 'client',
321+
logger: host._logger,
322+
}),
305323
getCache: () => host.resolveAdapterCache(),
306324
setCache: (value) => host.setAdapterCache(value),
307325
getInstanceId: () => host._runtimeDefaults.namespace?.instanceId,
@@ -331,26 +349,11 @@ export function createRuntimeAdapterBridge(host: RuntimeAdapterBridgeHost): Lega
331349
if (!name || typeof name !== 'string') {
332350
throw createError(ErrorCodes.INVALID_DATABASE_NAME, 'Database name is required and must be a non-empty string');
333351
}
334-
if (!adminOptions?.confirm) {
335-
const error = new Error(
336-
'dropDatabase requires explicit confirmation. Pass { confirm: true } to proceed.\n\n' +
337-
'⚠️ WARNING: This will DELETE ALL DATA in the database!\n' +
338-
'⚠️ This operation CANNOT BE UNDONE!',
339-
) as Error & { code: string };
340-
error.code = 'CONFIRMATION_REQUIRED';
341-
throw error;
342-
}
343-
const isProduction = isProductionEnvironment();
344-
if (isProduction && !adminOptions.allowProduction) {
345-
const error = new Error('dropDatabase is blocked in production. Pass { allowProduction: true } to override.') as Error & { code: string };
346-
error.code = 'PRODUCTION_BLOCKED';
347-
throw error;
348-
}
349-
if (!host._client) {
350-
throw createError(ErrorCodes.NOT_CONNECTED, 'MonSQLize is not connected yet.');
351-
}
352-
await host._client.db(name).dropDatabase();
353-
return { dropped: true, database: name, timestamp: new Date() };
352+
return host.db(name).dropDatabase({
353+
confirm: adminOptions?.confirm === true,
354+
allowProduction: adminOptions?.allowProduction,
355+
user: adminOptions?.user,
356+
});
354357
},
355358
listCollections: async (adminOptions) => {
356359
host.ensureConnected();

src/entry/runtime-core-hosts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type RuntimeCoreAdapterBridgeState = {
1313
options: MonSQLizeOptions;
1414
_cache: CacheLike;
1515
_adapterCacheOverride: CacheLike | null | undefined;
16+
_logger: Logger;
1617
} & Pick<
1718
RuntimeAdapterBridgeHost,
1819
'_defaultDb' | '_client' | '_iidCache' | '_runtimeDefaults' | '_slowQueryLogManager' |
@@ -57,6 +58,7 @@ export function createRuntimeCoreAdapterBridgeHost(runtime: unknown): RuntimeAda
5758
get _iidCache() { return state._iidCache; },
5859
set _iidCache(value) { state._iidCache = value; },
5960
_runtimeDefaults: state._runtimeDefaults,
61+
_logger: state._logger,
6062
get _slowQueryLogManager() { return state._slowQueryLogManager; },
6163
resolveAdapterCache: () => resolveAdapterCache(state),
6264
setAdapterCache: (value) => {
@@ -105,4 +107,4 @@ export function createRuntimeCoreAccessors<TRuntime extends object>(runtime: TRu
105107
state._iidCache = value;
106108
},
107109
});
108-
}
110+
}

test/unit/coverage/core-helpers.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,7 @@ describe('coverage core helpers', () => {
11851185
_client: { db: () => ({ collection: () => nativeCollection, dropDatabase: async () => true }) },
11861186
_iidCache: null,
11871187
_runtimeDefaults: { namespace: { instanceId: 'iid' } },
1188+
_logger: { warn: () => undefined },
11881189
_slowQueryLogManager: { save: async (entry: unknown) => saved.push(entry) },
11891190
resolveAdapterCache: () => null,
11901191
setAdapterCache: (value: unknown) => { host.cache = value; },
@@ -1196,6 +1197,15 @@ describe('coverage core helpers', () => {
11961197
listDatabases: async () => [{ name: 'db' }],
11971198
listCollections: async () => [{ name: 'items' }],
11981199
runCommand: async (cmd: unknown) => ({ cmd }),
1200+
dropDatabase: async (options: { confirm?: boolean; allowProduction?: boolean } = {}) => {
1201+
if (!options.confirm) {
1202+
throw new Error('dropDatabase requires explicit confirmation. Pass { confirm: true } to proceed.');
1203+
}
1204+
if (['production', 'prod', 'live'].includes(process.env['NODE_ENV'] ?? '') && !options.allowProduction) {
1205+
throw new Error('dropDatabase is blocked in production. Pass { allowProduction: true } to override.');
1206+
}
1207+
return { dropped: true, database: 'db', timestamp: new Date() };
1208+
},
11991209
}),
12001210
emit: (event: string, payload: unknown) => emitted.push({ event, payload }),
12011211
};

0 commit comments

Comments
 (0)