@anteros/core 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,23 +84,35 @@ async function initializeOnDatabase(collections: Collection[]) {
84
84
  }
85
85
 
86
86
  if (field?.index) {
87
- let indexValue: Boolean | number = field.index;
88
- let includesStringType: Field['type'][] = ['string', 'email', 'enum', 'url', 'random', 'slug'];
89
- let includesDateType: Field['type'][] = ['date', 'datetime-local'];
90
- if (typeof indexValue == "boolean" && includesStringType.includes(field.type)) {
91
- indexValue = 1
87
+ let indexValue: number | string = 1;
88
+
89
+ // Honor explicit indexType first
90
+ if (field.indexType) {
91
+ indexValue = field.indexType;
92
+ } else if (typeof field.index === 'number') {
93
+ indexValue = field.index;
94
+ } else if (field.type === 'geojson.Point' || field.type === 'geojson.LineString' || field.type === 'geojson.Polygon') {
95
+ indexValue = '2dsphere';
96
+ } else {
97
+ let includesStringType: Field['type'][] = ['string', 'email', 'enum', 'url', 'random', 'slug', 'relationship', 'uuid', 'password'];
98
+ let includesDateType: Field['type'][] = ['date', 'datetime-local'];
99
+ if (includesStringType.includes(field.type)) {
100
+ indexValue = 1;
101
+ } else if (includesDateType.includes(field.type)) {
102
+ indexValue = -1;
103
+ }
92
104
  }
93
105
 
94
- if (typeof indexValue == "boolean" && includesDateType.includes(field.type)) {
95
- indexValue = -1
96
- }
106
+ let isSparse = field.type === 'random'
107
+ || (field.unique && field.required !== true);
108
+
97
109
  specsFieldsIndexes.push({
98
110
  key: {
99
- [field.name]: indexValue as number
111
+ [field.name]: indexValue as any
100
112
  },
101
113
  unique: field.unique ?? false,
102
- sparse: field.type == 'random' ? true : false,
103
- ...field.indexOptions,
114
+ sparse: isSparse,
115
+ ...field.indexOptions, // explicit overrides win
104
116
  })
105
117
 
106
118
  }
@@ -111,6 +123,22 @@ async function initializeOnDatabase(collections: Collection[]) {
111
123
  if (specsFieldsIndexes.length) {
112
124
  await db.collection(collection.slug).createIndexes(specsFieldsIndexes)
113
125
  }
126
+
127
+ // Purge orphan indexes (fields removed from schema)
128
+ if (collection.purgeOrphanIndexes) {
129
+ const expectedKeys = new Set(specsFieldsIndexes.map((idx) =>
130
+ JSON.stringify(Object.keys(idx.key!).sort())
131
+ ));
132
+ const existingIndexes = await db.collection(collection.slug).listIndexes().toArray();
133
+ for (const idx of existingIndexes) {
134
+ if (idx.name === '_id_') continue; // built-in
135
+ const idxKeys = JSON.stringify(Object.keys(idx.key!).sort());
136
+ if (!expectedKeys.has(idxKeys)) {
137
+ await db.collection(collection.slug).dropIndex(idx.name!);
138
+ console.log(`🧹 Dropped orphan index: ${collection.slug}.${idx.name}`.gray);
139
+ }
140
+ }
141
+ }
114
142
  }
115
143
 
116
144
  } catch (err: any) {
@@ -10,7 +10,7 @@ export function CheckIfCollectionExists(): any {
10
10
  return descriptor;
11
11
  }
12
12
  const originalMethod = descriptor.value;
13
- descriptor.value = async function (...args: any[]) {
13
+ descriptor.value = async function (this: any, ...args: any[]) {
14
14
  let collection = args[0];
15
15
  let col = getCollection(collection, this.tenant_id)
16
16
  if (!col) {
@@ -42,18 +42,22 @@ class MongoRest {
42
42
  if (options.session) {
43
43
  this.session = options.session;
44
44
  }
45
- let tenant = getTenant(options?.tenant_id ?? '-')
46
- if (options.tenant_id && tenant) {
45
+ // Resolve tenant: explicit tenant_id, else from request context
46
+ let tenantId = options.tenant_id;
47
+ if (!tenantId) {
48
+ tenantId = requestCtxStorage.get<string>('tenant_id');
49
+ }
50
+ let tenant = getTenant(tenantId ?? '-')
51
+ if (tenant) {
47
52
  this.#tenant = tenant;
48
53
  this.tenant_id = tenant.id;
49
54
  this.db = tenant.database.db as Db;
50
55
  this.client = tenant.database.client as MongoClient;
51
56
  }
52
-
53
57
  }
54
58
 
55
59
 
56
- private _validate(options: { collection: string, action?: ActionsApiList, data?: any, update?: UpdateFilter<any> }) {
60
+ private _validate(options: { collection: string, action?: ActionsApiList, data?: any, update?: UpdateFilter<any> }): any {
57
61
  let col = getCollection(options.collection, this.#tenant.id)
58
62
  let schema = col?._schema_
59
63
  let partialSchema = col?._schemaPartial_
@@ -72,82 +76,81 @@ class MongoRest {
72
76
  })
73
77
 
74
78
 
75
- //let unauthorizedKeys = func.unauthorizedKeys(options.data, getCollectionKeys(col))
76
- /* if (unauthorizedKeys.length > 0) {
77
- throw new AppError(`Unauthorized keys in "${options.collection}" : ${unauthorizedKeys.join(', ')}`, {
78
- code: 'UNAUTHORIZED_KEYS',
79
- status: 400
80
- })
81
- } */
82
-
83
-
84
-
85
79
  if (schema && partialSchema) {
86
80
 
87
81
 
88
82
  if (options?.data) {
89
83
  if (options.action == 'insertMany') {
90
- options.data.map(d => {
91
- validationResult = schema.validate(d, {
92
- allowUnknown: false,
93
- })
94
- if (validationResult && validationResult.error) {
84
+ const sanitized: any[] = [];
85
+ for (let i = 0; i < options.data.length; i++) {
86
+ validationResult = schema.validate(options.data[i], { allowUnknown: false })
87
+ if (validationResult?.error) {
95
88
  throw new AppError(validationResult.error.message, {
96
89
  code: 'VALIDATION_ERROR',
97
90
  status: 400
98
91
  })
99
92
  }
100
- })
93
+ sanitized.push(validationResult.value)
94
+ }
95
+ return sanitized;
101
96
  }
102
97
 
103
98
  if (options.action == 'insertOne') {
104
- validationResult = schema.validate(options.data, {
105
- allowUnknown: false,
106
- })
99
+ validationResult = schema.validate(options.data, { allowUnknown: false })
100
+ if (validationResult?.error) {
101
+ throw new AppError(validationResult.error.message, {
102
+ code: 'VALIDATION_ERROR',
103
+ status: 400
104
+ })
105
+ }
106
+ // Return sanitized value (Joi defaults applied, unknown keys handled)
107
+ return validationResult.value;
107
108
  }
108
109
  }
109
110
 
110
111
 
111
112
  if (options?.update) {
112
- if (options.action == 'insertOne' && options.update?.$set) {
113
- validationResult = partialSchema.validate(options.update?.$set, {
114
- allowUnknown: false,
115
- })
116
- }
117
-
118
- if (options.action == 'insertMany' && options.update?.$set) {
119
- validationResult = partialSchema.validate(options.update?.$set, {
120
- allowUnknown: false,
121
- })
113
+ if ((options.action == 'insertOne' || options.action == 'insertMany') && options.update?.$set) {
114
+ validationResult = partialSchema.validate(options.update.$set, { allowUnknown: false })
122
115
  }
123
116
 
124
117
  if (options.action == "findOneAndUpdate" && options.update.$set) {
125
- validationResult = partialSchema.validate(options.update.$set, {
126
- allowUnknown: false,
127
- })
118
+ validationResult = partialSchema.validate(options.update.$set, { allowUnknown: false })
128
119
  }
129
120
 
130
121
  if (options.action == "findOneAndUpdate" && options.update.$setOnInsert) {
131
- validationResult = partialSchema.validate(options.update.$setOnInsert, {
132
- allowUnknown: false,
133
- })
122
+ validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
134
123
  }
135
- }
136
124
 
137
- if (validationResult?.error) {
138
- throw new AppError(validationResult.error.message, {
139
- code: 'VALIDATION_ERROR',
140
- status: 400
141
- })
125
+ // Faille 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
126
+ if ((options.action == 'updateOne' || options.action == 'updateMany') && options.update?.$set) {
127
+ const unflattened = func.unflattenKeys(options.update.$set)
128
+ validationResult = partialSchema.validate(unflattened, { allowUnknown: false })
129
+ if (validationResult?.error) {
130
+ throw new AppError(validationResult.error.message, {
131
+ code: 'VALIDATION_ERROR',
132
+ status: 400
133
+ })
134
+ }
135
+ // Write sanitized (nested) value back — MongoDB accepts nested objects
136
+ if (validationResult.value) {
137
+ options.update.$set = validationResult.value
138
+ }
139
+ }
140
+
141
+ if (validationResult?.error) {
142
+ throw new AppError(validationResult.error.message, {
143
+ code: 'VALIDATION_ERROR',
144
+ status: 400
145
+ })
146
+ }
147
+ return validationResult?.value;
142
148
  }
143
149
 
144
150
  }
145
-
146
-
147
-
148
151
  }
149
152
 
150
- private async #logActivity(data: {
153
+ async #logActivity(data: {
151
154
  action: string,
152
155
  collection: string,
153
156
  input?: any,
@@ -204,7 +207,7 @@ class MongoRest {
204
207
  await this.audit.addActivities([activity])
205
208
  }
206
209
 
207
- private async #executeWithAudit<T>(
210
+ async #executeWithAudit<T>(
208
211
  action: string,
209
212
  collection: string,
210
213
  input: any,
@@ -269,7 +272,17 @@ class MongoRest {
269
272
  try {
270
273
 
271
274
  const col = getCollection(collection, this.#tenant.id) as Collection
272
- const isSafe = func.isSafeAggregatePipeline(pipeline);
275
+ // Faille 12: restrict $lookup targets to this tenant's declared collections
276
+ const allowedCollections = [
277
+ ...(cfg.collections ?? [])
278
+ .filter(c => c._tenant_ === this.tenant_id)
279
+ .map(c => c.slug),
280
+ ...(cfg.fileCollections ?? [])
281
+ .filter(fc => fc._tenant_ === this.tenant_id)
282
+ .map(fc => fc.slug),
283
+ '_activities_', '_locks_', '_workflows_',
284
+ ];
285
+ const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
273
286
  if (!isSafe.isSafe) {
274
287
  throw isSafe.error;
275
288
  }
@@ -371,18 +384,19 @@ class MongoRest {
371
384
  data = await func.buildInput(data as T, {
372
385
  action: action, col: col, rest: this
373
386
  })
374
- this._validate({ collection, action: action, data: data })
375
387
 
376
388
  const meta: any = { action, collection, data }
377
389
  if (col.hooks?.beforeOperation) {
378
390
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
379
391
  }
392
+ // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
393
+ meta.data = this._validate({ collection, action: action, data: meta.data })
380
394
 
381
- await this.db.collection(collection).insertOne(func.toBson(data, { col }) as any, {
395
+ await this.db.collection(collection).insertOne(func.toBson(meta.data, { col }) as any, {
382
396
  session: this.session,
383
397
  })
384
398
 
385
- const result: T & { _id: string } = func.toJson(data as T & { _id: string })
399
+ const result: T & { _id: string } = func.toJson(meta.data as T & { _id: string })
386
400
  meta.result = result
387
401
  if (col?.hooks?.afterOperation) {
388
402
  await col.hooks.afterOperation({ rest: this, io, action, meta })
@@ -409,18 +423,18 @@ class MongoRest {
409
423
  dataInput.push(d)
410
424
  }
411
425
 
412
- this._validate({ collection, action: action, data: dataInput })
413
-
414
426
  const meta: any = { action, collection, data: dataInput }
415
427
  if (col.hooks?.beforeOperation) {
416
428
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
417
429
  }
430
+ // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
431
+ meta.data = this._validate({ collection, action: action, data: meta.data })
418
432
 
419
- await this.db.collection(collection).insertMany(func.toBson(dataInput, { col }) as any, {
433
+ await this.db.collection(collection).insertMany(func.toBson(meta.data, { col }) as any, {
420
434
  session: this.session,
421
435
  })
422
436
 
423
- const result: (T & { _id: string })[] = func.toJson(dataInput as (T & { _id: string })[])
437
+ const result: (T & { _id: string })[] = func.toJson(meta.data as (T & { _id: string })[])
424
438
  meta.result = result
425
439
 
426
440
  if (col.hooks?.afterOperation) {
@@ -769,14 +783,14 @@ class MongoRest {
769
783
 
770
784
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
771
785
 
772
- return await col.actions?.[action]({
786
+ return await col.actions?.[action]?.({
773
787
  rest: this,
774
- io,
788
+ io,
775
789
  data,
776
790
  error: fn.error,
777
791
  jwt: func.jwt,
778
- token: token ?? { value: null, decoded: null },
779
- })
792
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
793
+ } as any)
780
794
  }
781
795
 
782
796
  async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
@@ -793,15 +807,14 @@ class MongoRest {
793
807
 
794
808
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
795
809
 
796
- return await serviceInstance.actions?.[action]({
810
+ return await serviceInstance.actions?.[action]?.({
797
811
  data,
798
812
  error: fn.error,
799
813
  io,
800
814
  jwt: func.jwt,
801
815
  token: token ?? { value: null, decoded: null, provided: false, expired: false },
802
816
  rest: this,
803
- io,
804
- })
817
+ } as any)
805
818
  }
806
819
 
807
820
  async stats() {
@@ -918,8 +931,8 @@ class MongoRest {
918
931
 
919
932
  try {
920
933
  const result = await this.db.collection('_locks_').findOneAndUpdate(
921
- { _id: id, expiresAt: { $lt: now } },
922
- { $set: { _id: id, tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
934
+ { _id: id as any, expiresAt: { $lt: now } },
935
+ { $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
923
936
  { upsert: true, returnDocument: 'after' }
924
937
  );
925
938
  if (!result) {
package/lib/files.ts CHANGED
@@ -53,7 +53,8 @@ export function createDiskStorage(baseDir?: string): FileStorage {
53
53
  const filename = `${meta.id}${ext}`;
54
54
  const filepath = path.join(dir, filename);
55
55
  const buffer = await file.arrayBuffer();
56
- await fs.writeFile(filepath, new Uint8Array(buffer));
56
+ // Faille 17: 'wx' — fail if file already exists (no silent overwrite)
57
+ await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
57
58
  const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
58
59
  return { path: outPath, size: buffer.byteLength };
59
60
  },
@@ -146,6 +147,15 @@ async function transformImage(
146
147
  const format = options.format || 'webp';
147
148
  const quality = options.quality ?? 80;
148
149
 
150
+ // Faille 20: validate params (reject NaN / out-of-range)
151
+ if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
152
+ throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
153
+ }
154
+ if ((options.width != null && (!Number.isFinite(options.width) || options.width <= 0))
155
+ || (options.height != null && (!Number.isFinite(options.height) || options.height <= 0))) {
156
+ throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
157
+ }
158
+
149
159
  // Convertir ReadableStream en Uint8Array si nécessaire
150
160
  let buffer: Uint8Array;
151
161
  if (input instanceof Uint8Array) {
@@ -167,6 +177,12 @@ async function transformImage(
167
177
  }
168
178
  }
169
179
 
180
+ // Faille 20: only transform real images (magic bytes check)
181
+ const detected = detectMimeFromBuffer(buffer);
182
+ if (!detected || !detected.startsWith('image/')) {
183
+ throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
184
+ }
185
+
170
186
  // Utiliser Bun.Image (natif, zéro dépendance)
171
187
  const img = new Bun.Image(buffer);
172
188
 
@@ -249,6 +265,28 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
249
265
  });
250
266
  }
251
267
 
268
+ // Faille 4: validate magic bytes — don't trust client-declared Content-Type
269
+ const fullBuffer = new Uint8Array(await file.arrayBuffer());
270
+ const probe = fullBuffer.subarray(0, 4096);
271
+ const detected = detectMimeFromBuffer(probe);
272
+ const declaredMime = mimetype.toLowerCase();
273
+ const isImageDeclared = declaredMime.startsWith('image/');
274
+ const isImageDetected = detected?.startsWith('image/') ?? false;
275
+
276
+ // Reject content that lies about being an image (e.g. HTML/JS uploaded as image/png)
277
+ if (isImageDeclared && !isImageDetected) {
278
+ throw new AppError(`File content does not match declared MIME type '${mimetype}'`, {
279
+ code: 'MIMETYPE_MISMATCH', status: 400,
280
+ });
281
+ }
282
+
283
+ // Hardening: block SVG with embedded script (stored XSS)
284
+ if (detected === 'image/svg+xml' && svgContainsScript(probe)) {
285
+ throw new AppError('SVG with executable content is not allowed', {
286
+ code: 'SVG_SCRIPT_NOT_ALLOWED', status: 400,
287
+ });
288
+ }
289
+
252
290
  // Validate file size
253
291
  const maxSize = col.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
254
292
  if (file.size > maxSize) {
@@ -358,20 +396,39 @@ export async function handleServe(
358
396
  fileId: string,
359
397
  filename: string,
360
398
  transform?: TransformOptions,
361
- ): Promise<{ stream: ReadableStream; mimetype: string; size?: number } | null> {
399
+ ): Promise<{ stream: ReadableStream; mimetype: string; size?: number; attachment?: boolean } | null> {
362
400
  const storage = getStorageForCollection(collection, tenant_id);
363
401
  const col = getFileCollection(collection, tenant_id);
364
402
  const subpath = col?.storage?.path || undefined;
365
403
  const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
366
404
  if (!stream) return null;
367
405
 
368
- let mimetype = getMimeType(filename);
406
+ // Faille 10: serve the MIME validated at upload time (content-checked),
407
+ // not re-derived from the extension.
408
+ let mimetype: string | undefined;
409
+ try {
410
+ const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
411
+ const doc = await rest.db.collection(collection).findOne(
412
+ { _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any },
413
+ { projection: { '_file.mimetype': 1 } },
414
+ );
415
+ mimetype = (doc as any)?._file?.mimetype;
416
+ } catch {}
417
+
418
+ // Fallback: extension-derived MIME only when nothing stored
419
+ mimetype = mimetype || getMimeType(filename);
420
+ const isSvg = mimetype === 'image/svg+xml';
369
421
 
370
422
  if (transform && (transform.width || transform.height || transform.format)) {
371
423
  const { data, mimetype: newMime } = await transformImage(stream, transform);
372
424
  return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
373
425
  }
374
426
 
427
+ // Faille 4: SVG is served as an attachment (never inline) to prevent stored XSS
428
+ if (isSvg) {
429
+ return { stream, mimetype, attachment: true };
430
+ }
431
+
375
432
  return { stream, mimetype };
376
433
  }
377
434
 
@@ -389,6 +446,44 @@ function getMimeType(filename: string): string {
389
446
  return map[ext] || 'application/octet-stream';
390
447
  }
391
448
 
449
+ /**
450
+ * Detect a file's real type from its magic bytes.
451
+ * Returns null when unknown. Used to prevent content spoofing (XSS via SVG etc).
452
+ */
453
+ export function detectMimeFromBuffer(buf: Uint8Array): string | null {
454
+ const head = (offset: number, len: number) =>
455
+ String.fromCharCode(...buf.slice(offset, offset + len));
456
+ const hex = (offset: number, len: number) =>
457
+ Array.from(buf.slice(offset, offset + len)).map(b => b.toString(16).padStart(2, '0')).join('');
458
+
459
+ // JPEG: FF D8 FF
460
+ if (hex(0, 3) === 'ffd8ff') return 'image/jpeg';
461
+ // PNG: 89 50 4E 47
462
+ if (hex(0, 4) === '89504e47') return 'image/png';
463
+ // GIF: GIF87a / GIF89a
464
+ if (head(0, 4) === 'GIF8') return 'image/gif';
465
+ // WebP: RIFF....WEBP
466
+ if (head(0, 4) === 'RIFF' && head(8, 4) === 'WEBP') return 'image/webp';
467
+ // AVIF: ftypavif / ftypavis at offset 4
468
+ if (hex(4, 4) === '66747970' && (head(8, 4) === 'avif' || head(8, 4) === 'avis')) return 'image/avif';
469
+ // BMP: BM
470
+ if (head(0, 2) === 'BM') return 'image/bmp';
471
+ // PDF: %PDF
472
+ if (head(0, 4) === '%PDF') return 'application/pdf';
473
+ // ZIP (docx/xlsx/etc): PK\x03\x04
474
+ if (hex(0, 4) === '504b0304') return 'application/zip';
475
+ // SVG: XML declaration or <svg — MUST be served as attachment
476
+ const text = new TextDecoder().decode(buf.subarray(0, 2048));
477
+ if (/^\s*(<\?xml|<!DOCTYPE|<svg)/i.test(text)) return 'image/svg+xml';
478
+ return null;
479
+ }
480
+
481
+ /** True if the file content contains executable script (used for SVG hardening) */
482
+ function svgContainsScript(buf: Uint8Array): boolean {
483
+ const text = new TextDecoder().decode(buf.subarray(0, 65536)); // first 64KB is enough
484
+ return /<script|onload\s*=|onerror\s*=|javascript:/i.test(text);
485
+ }
486
+
392
487
  export async function handleDelete(
393
488
  tenant_id: string,
394
489
  collection: string,
@@ -403,7 +498,7 @@ export async function handleDelete(
403
498
  try {
404
499
  const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
405
500
  const doc = await rest.db.collection(collection).findOne(
406
- { _id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId },
501
+ { _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any },
407
502
  { projection: { '_file.filename': 1 } },
408
503
  );
409
504
  filename = doc?._file?.filename;
@@ -426,7 +521,7 @@ export async function handleDelete(
426
521
  try {
427
522
  const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
428
523
  await rest.db.collection(collection).deleteOne({
429
- _id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId,
524
+ _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
430
525
  });
431
526
  } catch (err: any) {
432
527
  console.error('Failed to delete file metadata:', err?.message || err);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.18",
4
+ "version": "0.0.20",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
@@ -21,7 +21,7 @@
21
21
  "bentocache": "1.6.1",
22
22
  "boxen": "^8.0.1",
23
23
  "dayjs": "1.11.21",
24
- "hono": "4.13.0",
24
+ "hono": "^4.13.1",
25
25
  "ioredis": "6.0.0",
26
26
  "joi": "18.2.3",
27
27
  "jose": "6.2.8",
package/server/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Hono } from "hono"
1
+ import type { Hono, Context } from "hono"
2
2
  import { cfg, safePublicConfig } from "./config";
3
3
  import type { ApiOptions } from "../types/api";
4
4
  import { getCollection } from "../database/collection";
@@ -244,11 +244,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
244
244
  }
245
245
 
246
246
  // ─── Collection API ─────────────────────────────────────────────────
247
- app.post(API_PREFIX, async (c) => {
247
+ app.post(API_PREFIX, async (c: Context<{ Variables: HonoVariables }>) => {
248
248
  let response: any;
249
249
  let body: any;
250
250
  let rest: InstanceType<typeof useRest>;
251
- let canAccessToAction: boolean | undefined;
252
251
 
253
252
  try {
254
253
  const ContentType = c.req.header('Content-Type');
@@ -274,6 +273,9 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
274
273
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
275
274
  }
276
275
 
276
+ // Store tenant in request context so nested useRest() calls (hooks, actions) resolve it
277
+ requestCtxStorage.set('tenant_id', tenant_id);
278
+
277
279
  const col = getCollection(collection, tenant_id)
278
280
  if (!col) {
279
281
  throw new AppError('Collection `' + collection + '` not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
@@ -344,27 +346,8 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
344
346
  }
345
347
 
346
348
  // ── Access control ──
347
- const access = col?.api?.access as Record<string, boolean | Function> | undefined;
348
-
349
- if (access?.['*'] !== undefined && access?.[action] === undefined) {
350
- if (typeof access['*'] === 'function') {
351
- canAccessToAction = await access['*']({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
352
- } else {
353
- canAccessToAction = access['*'] as boolean;
354
- }
355
- if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
356
- }
357
-
358
- if (access?.[action] !== undefined) {
359
- canAccessToAction = typeof access[action] === 'function'
360
- ? await (access[action] as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken })
361
- : access[action] as boolean;
362
- if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
363
- }
364
-
365
- if (canAccessToAction !== true) {
366
- throw new AppError('Unauthorized', { status: 401, code: 'ACCESS_DENIED_TO_PERFORM_THIS_ACTION' });
367
- }
349
+ // Faille 7: single implementation via evaluateAccess (checks expired/forged tokens)
350
+ await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
368
351
 
369
352
  // ── Execute action ──
370
353
  if (col.actions?.[action]) {
@@ -415,6 +398,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
415
398
  const { service, tenant_id } = c.req.param() as { service: string, tenant_id: string };
416
399
  if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
417
400
  if (!service) throw new AppError('Service is required', { status: 400, code: 'SERVICE_REQUIRED' });
401
+ requestCtxStorage.set('tenant_id', tenant_id);
418
402
 
419
403
  const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
420
404
  const { action } = c.req.param() as { action: string };
@@ -457,6 +441,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
457
441
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
458
442
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
459
443
  }
444
+ requestCtxStorage.set('tenant_id', tenant_id);
460
445
 
461
446
  const colUpload = getFileCollection(collection, tenant_id);
462
447
  if (!colUpload) {
@@ -469,6 +454,16 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
469
454
  throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
470
455
  }
471
456
 
457
+ // Faille 11: reject oversized bodies BEFORE parseBody buffers them in memory
458
+ const contentLength = Number(c.req.header('Content-Length') ?? 0);
459
+ const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
460
+ // multipart overhead (boundaries/headers) ≈ 64KB max
461
+ if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxUploadSize + 64 * 1024) {
462
+ throw new AppError(`Request too large (max ${Math.round(maxUploadSize / 1024 / 1024)}MB)`, {
463
+ code: 'FILE_TOO_LARGE', status: 413,
464
+ });
465
+ }
466
+
472
467
  const formData = await c.req.parseBody();
473
468
  const fileField = formData['file'] || formData['upload'];
474
469
  if (!fileField || !(fileField instanceof File)) {
@@ -498,6 +493,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
498
493
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
499
494
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
500
495
  }
496
+ requestCtxStorage.set('tenant_id', tenant_id);
501
497
 
502
498
  const colServe = getFileCollection(collection, tenant_id);
503
499
  if (!colServe) {
@@ -505,10 +501,12 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
505
501
  }
506
502
  await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
507
503
 
508
- const filename = basename(c.req.param('file') as string);
509
- if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
504
+ // Faille 18: validate the RAW path before basename (checks were dead code)
505
+ const rawFile = c.req.param('file') as string;
506
+ if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
510
507
  throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
511
508
  }
509
+ const filename = basename(rawFile);
512
510
  const fileId = filename.replace(/\.[^.]+$/, '');
513
511
 
514
512
  const query = c.req.query();
@@ -529,6 +527,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
529
527
  c.header('Content-Type', result.mimetype);
530
528
  if (result.size) c.header('Content-Length', String(result.size));
531
529
  c.header('Cache-Control', 'public, max-age=31536000, immutable');
530
+ // Faille 4: SVG served as attachment (no inline execution)
531
+ if (result.attachment) {
532
+ c.header('Content-Disposition', `attachment; filename="${filename}"`);
533
+ }
532
534
  return c.newResponse(result.stream);
533
535
  } catch (err: any) {
534
536
  if (cfg?.debug) console.error(err)
@@ -544,6 +546,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
544
546
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
545
547
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
546
548
  }
549
+ requestCtxStorage.set('tenant_id', tenant_id);
547
550
 
548
551
  const colDelete = getFileCollection(collection, tenant_id);
549
552
  if (!colDelete) {
@@ -567,7 +570,8 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
567
570
  // ─── Public config ──────────────────────────────────────────────────
568
571
  app.get('/_dnax/config/:tenant_id', (c) => {
569
572
  const t = c.get('token');
570
- if (!t?.value) {
573
+ // Faille 6: require a VERIFIED, non-expired token (not just any provided value)
574
+ if (!t?.decoded || t?.expired || !t?.provided) {
571
575
  return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
572
576
  }
573
577
  const { tenant_id } = c.req.param() as { tenant_id: string };
package/server/boot.ts CHANGED
@@ -17,6 +17,20 @@ import { loadServices } from '../lib/services'
17
17
  import { loadSockets } from '../lib/sockets'
18
18
  import { syncWorkflows } from '../lib/workflow'
19
19
  import { loadTenantsMiddlewares } from '../lib/middleware'
20
+ import crypto from 'node:crypto';
21
+
22
+ /** Check if a TCP port is already bound */
23
+ async function isPortInUse(port: number): Promise<boolean> {
24
+ try {
25
+ const proc = Bun.spawn(['lsof', '-ti', `:${port}`], { stdout: 'pipe' });
26
+ const output = await new Response(proc.stdout).text();
27
+ await proc.exited;
28
+ return output.trim().length > 0;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
20
34
  type BootAppOptions = ServerConfig & {
21
35
 
22
36
  }
@@ -38,17 +52,50 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
38
52
  //******************************* */
39
53
 
40
54
 
55
+ // JWT_SECRET check: auto-generate if missing and auth is enabled
56
+ const hasAuth = cfg.collections?.some((c: any) => c.api?.auth?.enabled);
57
+ let jwtSecret = cfg.server.jwt?.secret || Bun.env.JWT_SECRET;
58
+ if (hasAuth && !jwtSecret) {
59
+ // CSPRNG: crypto.randomBytes (not Math.random — predictable)
60
+ jwtSecret = crypto.randomBytes(48).toString('base64url');
61
+ try {
62
+ const envPath = '.env';
63
+ const exists = await Bun.file(envPath).exists();
64
+ const content = exists ? await Bun.file(envPath).text() : '';
65
+ if (!content.includes('JWT_SECRET=')) {
66
+ const suffix = content ? '\n' : '';
67
+ await Bun.write(envPath, `${content}${suffix}# Auto-generated on boot\nJWT_SECRET=${jwtSecret}\n`);
68
+ }
69
+ Bun.env.JWT_SECRET = jwtSecret;
70
+ console.log('🔐 JWT_SECRET auto-generated and saved to .env'.gray);
71
+ } catch {
72
+ Bun.env.JWT_SECRET = jwtSecret;
73
+ console.log('⚠ JWT_SECRET auto-generated (in-memory only, could not write .env)'.yellow);
74
+ }
75
+ }
76
+
41
77
  const PORT = (cfg.server.port || 4000);
42
78
  const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
43
79
  const useClusterMode = cfg.clusterMode || false;
44
80
  const env = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
81
+ // reusePort: cluster mode ON by default, dev mode OFF to detect zombies
82
+ const reusePort = cfg.server.reusePort ?? (useClusterMode && env !== 'dev');
83
+
84
+ // Detect zombie: check if port is already in use in dev mode
85
+ if (!reusePort && env === 'dev') {
86
+ const occupied = await isPortInUse(PORT);
87
+ if (occupied) {
88
+ console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n → Or set server.reusePort: true in config\n`.red.bold);
89
+ process.exit(1);
90
+ }
91
+ }
45
92
 
46
93
 
47
94
  const app = createApp();
48
95
 
49
96
  const server = Bun.serve({
50
97
  port: PORT,
51
- reusePort: useClusterMode,
98
+ reusePort,
52
99
  fetch: (req, server) => {
53
100
  const url = new URL(req.url);
54
101
 
package/server/hono.ts CHANGED
@@ -114,11 +114,15 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
114
114
 
115
115
 
116
116
 
117
+ const requestCtx: Record<string, string> = c.req.header();
118
+ // Faille 1: never log sensitive headers (JWT, cookies)
119
+ delete requestCtx.authorization;
120
+ delete requestCtx.cookie;
117
121
  requestCtxStorage.set('meta', {
118
122
  request: {
119
123
  ip: connInfo.remote.address ?? c.req.header('CF-Connecting-IP') ?? c.req.header('True-Client-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown',
120
124
  user_agent: c.req.header('User-Agent') ?? '',
121
- headers: c.req.header(),
125
+ headers: requestCtx,
122
126
  method: c.req.method,
123
127
  path: c.req.path,
124
128
  query: c.req.query(),
@@ -129,15 +133,14 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
129
133
  })
130
134
  const bearer = c.req.header('Authorization')?.replace('Bearer ', '');
131
135
  if (bearer) {
132
- const { value, error } = await jwt.verify(bearer);
136
+ const { value, error, expired } = await jwt.verify(bearer);
133
137
  const isValid = !error && value != null;
134
- const isExpired = !!error && error.toLowerCase().includes('exp');
135
138
 
136
139
  const tokenData = {
137
- value: bearer,
140
+ value: isValid ? bearer : null,
138
141
  decoded: isValid ? (value as Record<string, unknown>) : null,
139
142
  provided: true,
140
- expired: isExpired || (value != null && typeof (value as any)?.exp === 'number' && (value as any).exp * 1000 < Date.now()),
143
+ expired: expired || (value != null && typeof (value as any)?.exp === 'number' && (value as any).exp * 1000 < Date.now()),
141
144
  };
142
145
 
143
146
  requestCtxStorage.set('token', tokenData);
package/tests/api.test.ts CHANGED
@@ -11,6 +11,7 @@ let rest: InstanceType<typeof useRest>;
11
11
 
12
12
  beforeAll(async () => {
13
13
  formatConfig({
14
+ server: { port: 4000 },
14
15
  tenants: [{ id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } }],
15
16
  });
16
17
  await syncTenants();
@@ -45,7 +46,7 @@ describe("insertOne", () => {
45
46
  expect(doc._id.length).toBe(24);
46
47
  });
47
48
  it("sets createdAt and updatedAt", async () => {
48
- const doc = await rest.insertOne("items", { title: "ts" });
49
+ const doc: any = await rest.insertOne("items", { title: "ts" });
49
50
  expect(doc.createdAt).toBeString();
50
51
  expect(doc.updatedAt).toBeString();
51
52
  });
@@ -84,8 +85,8 @@ describe("findOne", () => {
84
85
 
85
86
  describe("updateOne", () => {
86
87
  it("$set title and changes updatedAt", async () => {
87
- const doc = await rest.insertOne("items", { title: "old" });
88
- const upd = await rest.updateOne("items", doc._id, { $set: { title: "new" } });
88
+ const doc: any = await rest.insertOne("items", { title: "old" });
89
+ const upd: any = await rest.updateOne("items", doc._id, { $set: { title: "new" } });
89
90
  expect(upd.title).toBe("new");
90
91
  expect(upd.updatedAt).not.toBe(doc.updatedAt);
91
92
  });
@@ -93,17 +94,18 @@ describe("updateOne", () => {
93
94
 
94
95
  describe("deleteOne", () => {
95
96
  it("deletes and returns _id", async () => {
96
- const doc = await rest.insertOne("items", { title: "del-me" });
97
- expect((await rest.deleteOne("items", doc._id))._id).toBe(doc._id);
97
+ const doc: any = await rest.insertOne("items", { title: "del-me" });
98
+ const deleted: any = await rest.deleteOne("items", doc._id);
99
+ expect(deleted?._id).toBe(doc._id);
98
100
  });
99
101
  });
100
102
 
101
103
  describe("insertMany", () => {
102
104
  it("inserts and returns array with _id", async () => {
103
- const docs = await rest.insertMany("items", [{ title: "m1" }, { title: "m2" }]);
105
+ const docs: any[] = await rest.insertMany("items", [{ title: "m1" }, { title: "m2" }]);
104
106
  expect(docs.length).toBe(2);
105
- expect(docs[0]._id).toBeString();
106
- expect(docs[1]._id).toBeString();
107
+ expect(docs[0]!._id).toBeString();
108
+ expect(docs[1]!._id).toBeString();
107
109
  });
108
110
  });
109
111
 
@@ -173,7 +175,7 @@ describe("bulkUpdate", () => {
173
175
 
174
176
  describe("stats", () => {
175
177
  it("returns collection stats", async () => {
176
- const s = await rest.stats("items");
178
+ const s: any = await rest.stats();
177
179
  expect(s).toBeDefined();
178
180
  expect(typeof s).toBe("object");
179
181
  });
@@ -184,7 +186,7 @@ describe("audit", () => {
184
186
  await rest.audit.addActivities([{
185
187
  internal: false,
186
188
  trace: { id: crypto.randomUUID() },
187
- request: null,
189
+ request: undefined,
188
190
  meta: {},
189
191
  operation: {
190
192
  tenant: TEST_TENANT,
@@ -199,7 +201,7 @@ describe("audit", () => {
199
201
  },
200
202
  ts: new Date(),
201
203
  }]);
202
- const activities = await rest.audit.getActivities({ $limit: 10 });
204
+ const activities = await rest.audit.getActivities({ $match: {}, $limit: 10 });
203
205
  expect(Array.isArray(activities)).toBe(true);
204
206
  expect(activities.length).toBeGreaterThanOrEqual(1);
205
207
  });
@@ -266,7 +268,7 @@ describe("transactions", () => {
266
268
 
267
269
  describe("watch", () => {
268
270
  it("returns a change stream", async () => {
269
- const stream = await rest.watch("items", [{ $match: { operationType: "insert" } }]);
271
+ const stream = await rest.watch("items", [{ $match: { operationType: "insert" } }], {} as any);
270
272
  expect(stream).toBeDefined();
271
273
  await stream.close();
272
274
  });
@@ -0,0 +1,15 @@
1
+ import type { Document, ChangeStreamOptions, ChangeStream } from "mongodb";
2
+
3
+ export class Test {
4
+ #tenant!: { id: string };
5
+ @Check()
6
+ async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
7
+ return undefined as any;
8
+ }
9
+ @Check()
10
+ async find(collection: string, params: any = {}, options = {}): Promise<Document[]> {
11
+ return [];
12
+ }
13
+ }
14
+
15
+ function Check() { return (target: any, key: string) => {} }
package/types/api.d.ts CHANGED
@@ -32,6 +32,17 @@ export type ActionsApiList =
32
32
  | "login"
33
33
  | "logout"
34
34
  | "aggregate"
35
+ | "watch"
36
+ | "countDocuments"
37
+ | "bulkWrite"
38
+ | "bulkUpdate"
39
+ | "replaceOne"
40
+ | "findOneAndUpdate"
41
+ | "dropCollection"
42
+ | "dropIndex"
43
+ | "dropIndexes"
44
+ | "stats"
45
+ | "workflows"
35
46
  | "*"
36
47
 
37
48
 
@@ -70,6 +70,8 @@ export type Collection = {
70
70
  */
71
71
  _tenant_?: string;
72
72
  _isTimeSerie_?: boolean;
73
+ /** Drop MongoDB indexes for fields no longer in the schema (default: false) */
74
+ purgeOrphanIndexes?: boolean;
73
75
  _isCollection_?: boolean;
74
76
  _schema_?: Joi.Schema;
75
77
  _schemaPartial_?: Joi.Schema;
package/types/config.d.ts CHANGED
@@ -13,6 +13,7 @@ export type ServerConfig = {
13
13
  server: {
14
14
  name?: string;
15
15
  port: number;
16
+ reusePort?: boolean; // override Bun's reusePort (default: clusterMode && not dev)
16
17
  body?: {
17
18
  maxSize?: number;
18
19
  };
package/utils/func.ts CHANGED
@@ -36,7 +36,7 @@ const jwt = {
36
36
  const token = await signer.sign(secret);
37
37
  return token;
38
38
  },
39
- verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
39
+ verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null, expired: boolean }> => {
40
40
  try {
41
41
  if (!token) throw new Error('Token is required');
42
42
  let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
@@ -45,12 +45,17 @@ const jwt = {
45
45
  const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
46
46
  return {
47
47
  value: payload,
48
- error: null
48
+ error: null,
49
+ expired: false,
49
50
  }
50
- } catch (error) {
51
+ } catch (error: any) {
52
+ // Reliable expiry detection via jose error code (not string matching)
53
+ const isExpired = error?.code === 'ERR_JWT_EXPIRED'
54
+ || /expired|expiration/i.test(error?.message ?? '');
51
55
  return {
52
56
  value: null,
53
- error: error instanceof Error ? error.message : 'Invalid token'
57
+ error: error instanceof Error ? error.message : 'Invalid token',
58
+ expired: isExpired,
54
59
  }
55
60
  }
56
61
  }
@@ -152,6 +157,48 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
152
157
  return result as Partial<T>;
153
158
  }
154
159
 
160
+ /**
161
+ * Convertit la dot-notation MongoDB en structure imbriquée pour la validation Joi.
162
+ * Ex: { "address.zip": "BP28", "items.0.name": "x" } → { address: { zip }, items: [{ name }] }
163
+ * Les clés opérateurs ($inc, $push…) sont laissées telles quelles.
164
+ */
165
+ function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
166
+ const result: Record<string, unknown> = {};
167
+ for (const [key, value] of Object.entries(input)) {
168
+ // Opérateurs Mongo → laisser tel quel (interdits dans $set de toute façon)
169
+ if (key.startsWith('$')) {
170
+ result[key] = value;
171
+ continue;
172
+ }
173
+ const parts = key.split('.');
174
+ let target: any = result;
175
+ for (let i = 0; i < parts.length - 1; i++) {
176
+ const part = parts[i]!;
177
+ const nextPart = parts[i + 1]!;
178
+ const isArrayIndex = /^\d+$/.test(nextPart);
179
+ if (typeof target[part] !== 'object' || target[part] === null) {
180
+ target[part] = isArrayIndex ? [] : {};
181
+ }
182
+ target = target[part];
183
+ }
184
+ const last = parts[parts.length - 1]!;
185
+ // Fusion si les deux côtés sont des objets (ex. "a.b" + a: { c })
186
+ if (
187
+ target[last] &&
188
+ typeof target[last] === 'object' &&
189
+ !Array.isArray(target[last]) &&
190
+ typeof value === 'object' &&
191
+ value !== null &&
192
+ !Array.isArray(value)
193
+ ) {
194
+ target[last] = { ...(target[last] as object), ...(value as object) };
195
+ } else {
196
+ target[last] = value;
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+
155
202
  function toJson<T>(data: any): T {
156
203
  if (data === null || data === undefined) return data;
157
204
  try {
@@ -372,7 +419,10 @@ function formatToObjectId<T>(value: T): FormatToObjectIdResult<T> {
372
419
  return value as FormatToObjectIdResult<T>;
373
420
  }
374
421
 
375
-
422
+ // Pass through primitives and Date instances untouched
423
+ if (value instanceof Date) {
424
+ return value as FormatToObjectIdResult<T>;
425
+ }
376
426
 
377
427
  if (typeof value === 'string') {
378
428
 
@@ -608,7 +658,15 @@ function collectDateFieldPathsFromCollection(col: Collection): string[] {
608
658
 
609
659
  function toBson<T>(data: any | any[], _options?: FormatInputOptions): T {
610
660
  let data_ = formatToObjectId(data);
611
- data_ = formatToDate(data_);
661
+
662
+ // Build omitKeys for fields that should NOT be date-converted (random, number, integer)
663
+ const omitKeys: string[] = [];
664
+ for (const f of _options?.col?.fields ?? []) {
665
+ if (f.type === 'random' || f.type === 'number' || f.type === 'integer') {
666
+ omitKeys.push(f.name);
667
+ }
668
+ }
669
+ data_ = formatToDate(data_, { omitKeys: omitKeys.length > 0 ? omitKeys : undefined });
612
670
 
613
671
  if (Array.isArray(data)) {
614
672
  data.length = 0;
@@ -634,12 +692,34 @@ function buildPipeline(p: FindOptions, options?: {
634
692
  status: 400
635
693
  });
636
694
  }
695
+ // Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
696
+ boundMatchOperators(p.$match);
637
697
  pipeline.push({
638
698
  $match: p.$match
639
699
  })
640
700
  }
641
701
 
642
- // $sort
702
+ // $sort — Faille 14: values must be 1 or -1
703
+ if (p?.$sort) {
704
+ for (const [key, dir] of Object.entries(p.$sort)) {
705
+ if (dir !== 1 && dir !== -1) {
706
+ throw new AppError(`Invalid $sort direction for '${key}' (expected 1 or -1)`, {
707
+ code: 'INVALID_SORT_DIRECTION',
708
+ status: 400
709
+ });
710
+ }
711
+ }
712
+ }
713
+
714
+ // $skip — Faille 14: must be a safe non-negative integer
715
+ if (p?.$skip !== undefined && p?.$skip !== null) {
716
+ if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
717
+ throw new AppError('Invalid $skip (expected a non-negative integer)', {
718
+ code: 'INVALID_SKIP',
719
+ status: 400
720
+ });
721
+ }
722
+ }
643
723
  if (!p?.$sort?.createdAt) {
644
724
  pipeline.push({
645
725
  $sort: {
@@ -653,20 +733,25 @@ function buildPipeline(p: FindOptions, options?: {
653
733
  })
654
734
  }
655
735
 
656
- // $skip
736
+ // $skip (validated above)
657
737
  if (p?.$skip) {
658
738
  pipeline.push({
659
739
  $skip: p.$skip
660
740
  })
661
741
  }
662
742
 
663
- // $limit
743
+ // $limit — Faille 14: must be a safe integer in (0, MAX_LIMIT]; 0 would mean "no limit"
744
+ const MAX_LIMIT = 1000;
664
745
  p.$limit = p?.$limit ?? 100
665
- if (p?.$limit > 0) {
666
- pipeline.push({
667
- $limit: p.$limit
668
- })
746
+ if (!Number.isSafeInteger(p.$limit) || p.$limit <= 0 || p.$limit > MAX_LIMIT) {
747
+ throw new AppError(`Invalid $limit (expected an integer between 1 and ${MAX_LIMIT})`, {
748
+ code: 'INVALID_LIMIT',
749
+ status: 400
750
+ });
669
751
  }
752
+ pipeline.push({
753
+ $limit: p.$limit
754
+ })
670
755
 
671
756
  // $include
672
757
  if (p?.$include && Array.isArray(p?.$include)) {
@@ -731,6 +816,17 @@ function buildPipeline(p: FindOptions, options?: {
731
816
 
732
817
  let as = include.as || include.localField
733
818
 
819
+ // Faille 3: never trust client-supplied $lookup sub-pipeline — scan it
820
+ if (include.pipeline && Array.isArray(include.pipeline)) {
821
+ const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
822
+ if (!check.isSafe) {
823
+ throw check.error ?? new AppError('Unauthorized $include pipeline', {
824
+ code: 'UNAUTHORIZED_PIPELINE_KEYS',
825
+ status: 400
826
+ })
827
+ }
828
+ }
829
+
734
830
  pipeline.push({
735
831
  $lookup: {
736
832
  from: include.from,
@@ -796,6 +892,46 @@ function clone<T>(data: T): T {
796
892
  return data_
797
893
  }
798
894
 
895
+ /**
896
+ * Faille 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
897
+ * Walks nested objects/arrays ($and, $or, $nor, dotted fields).
898
+ */
899
+ const MAX_REGEX_LENGTH = 1000;
900
+ const MAX_IN_ARRAY_SIZE = 10000;
901
+
902
+ function boundMatchOperators(node: unknown): void {
903
+ if (node === null || node === undefined) return;
904
+
905
+ if (Array.isArray(node)) {
906
+ for (const item of node) {
907
+ boundMatchOperators(item);
908
+ }
909
+ return;
910
+ }
911
+
912
+ if (typeof node === 'object') {
913
+ const obj = node as Record<string, unknown>;
914
+ for (const [key, value] of Object.entries(obj)) {
915
+ if (key === '$regex' && typeof value === 'string') {
916
+ if (value.length > MAX_REGEX_LENGTH) {
917
+ throw new AppError(`$regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`, {
918
+ code: 'REGEX_TOO_LONG',
919
+ status: 400
920
+ });
921
+ }
922
+ } else if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
923
+ if (value.length > MAX_IN_ARRAY_SIZE) {
924
+ throw new AppError(`$in/$nin too large (max ${MAX_IN_ARRAY_SIZE} items)`, {
925
+ code: 'ARRAY_OPERATOR_TOO_LARGE',
926
+ status: 400
927
+ });
928
+ }
929
+ }
930
+ boundMatchOperators(value);
931
+ }
932
+ }
933
+ }
934
+
799
935
 
800
936
 
801
937
 
@@ -996,14 +1132,18 @@ function countAggregatePipelineStages(pipeline: unknown): number {
996
1132
  }
997
1133
 
998
1134
 
999
- function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1135
+ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allowedCollections?: string[]): {
1000
1136
  isSafe: boolean;
1001
1137
  forbiddenKeys: string[];
1002
1138
  error: AppError | null;
1003
1139
  } {
1004
1140
 
1005
- // Unauthorized pipeline keys
1006
- const UNAUTHORIZED_PIPELINE_KEYS = ['$out', '$merge', '$function', '$where', '$accumulator'];
1141
+ // Unauthorized pipeline keys — Faille 12: include cross-collection exfiltration stages
1142
+ const UNAUTHORIZED_PIPELINE_KEYS = [
1143
+ '$out', '$merge', '$function', '$where', '$accumulator',
1144
+ '$unionWith', '$collStats', '$indexStats', '$planCacheStats',
1145
+ '$currentOp', '$listLocalSessions', '$listSessions', '$changeStream',
1146
+ ];
1007
1147
  const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
1008
1148
  if (forbiddenFound.length > 0) {
1009
1149
  return {
@@ -1013,6 +1153,28 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1013
1153
  }
1014
1154
  }
1015
1155
 
1156
+ // Faille 12: restrict $lookup / $graphLookup targets to declared tenant collections
1157
+ if (allowedCollections && allowedCollections.length > 0) {
1158
+ const badTargets: string[] = [];
1159
+ walkAggregatePipeline(pipeline, (stage) => {
1160
+ const lookup = (stage as any).$lookup;
1161
+ if (lookup?.from && !allowedCollections.includes(lookup.from)) {
1162
+ badTargets.push(lookup.from);
1163
+ }
1164
+ const graph = (stage as any).$graphLookup;
1165
+ if (graph?.from && !allowedCollections.includes(graph.from)) {
1166
+ badTargets.push(graph.from);
1167
+ }
1168
+ });
1169
+ if (badTargets.length > 0) {
1170
+ return {
1171
+ isSafe: false,
1172
+ forbiddenKeys: badTargets,
1173
+ error: new AppError('Unauthorized $lookup target collection: ' + [...new Set(badTargets)].join(', '), { code: 'UNAUTHORIZED_LOOKUP_TARGET', status: 400 })
1174
+ };
1175
+ }
1176
+ }
1177
+
1016
1178
  // $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
1017
1179
  let renameForbiddenKeys: string[] = [];
1018
1180
  let renameError: AppError | null = null;
@@ -1055,6 +1217,7 @@ export {
1055
1217
  reorder,
1056
1218
  omit,
1057
1219
  pick,
1220
+ unflattenKeys,
1058
1221
  toJson,
1059
1222
  stringToBoolean,
1060
1223
  cleanDeep,