@anteros/core 0.0.19 → 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.
@@ -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_
@@ -77,6 +81,7 @@ class MongoRest {
77
81
 
78
82
  if (options?.data) {
79
83
  if (options.action == 'insertMany') {
84
+ const sanitized: any[] = [];
80
85
  for (let i = 0; i < options.data.length; i++) {
81
86
  validationResult = schema.validate(options.data[i], { allowUnknown: false })
82
87
  if (validationResult?.error) {
@@ -85,9 +90,9 @@ class MongoRest {
85
90
  status: 400
86
91
  })
87
92
  }
88
- // Apply Joi defaults (e.g., .default() values) back to data
89
- options.data[i] = validationResult.value
93
+ sanitized.push(validationResult.value)
90
94
  }
95
+ return sanitized;
91
96
  }
92
97
 
93
98
  if (options.action == 'insertOne') {
@@ -98,8 +103,8 @@ class MongoRest {
98
103
  status: 400
99
104
  })
100
105
  }
101
- // Persist Joi defaults back to data
102
- if (validationResult.value) options.data = validationResult.value
106
+ // Return sanitized value (Joi defaults applied, unknown keys handled)
107
+ return validationResult.value;
103
108
  }
104
109
  }
105
110
 
@@ -117,22 +122,35 @@ class MongoRest {
117
122
  validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
118
123
  }
119
124
 
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
+
120
141
  if (validationResult?.error) {
121
142
  throw new AppError(validationResult.error.message, {
122
143
  code: 'VALIDATION_ERROR',
123
144
  status: 400
124
145
  })
125
146
  }
147
+ return validationResult?.value;
126
148
  }
127
149
 
128
150
  }
129
151
  }
130
152
 
131
-
132
-
133
- }
134
-
135
- private async #logActivity(data: {
153
+ async #logActivity(data: {
136
154
  action: string,
137
155
  collection: string,
138
156
  input?: any,
@@ -189,7 +207,7 @@ class MongoRest {
189
207
  await this.audit.addActivities([activity])
190
208
  }
191
209
 
192
- private async #executeWithAudit<T>(
210
+ async #executeWithAudit<T>(
193
211
  action: string,
194
212
  collection: string,
195
213
  input: any,
@@ -254,7 +272,17 @@ class MongoRest {
254
272
  try {
255
273
 
256
274
  const col = getCollection(collection, this.#tenant.id) as Collection
257
- 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);
258
286
  if (!isSafe.isSafe) {
259
287
  throw isSafe.error;
260
288
  }
@@ -362,7 +390,7 @@ class MongoRest {
362
390
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
363
391
  }
364
392
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
365
- this._validate({ collection, action: action, data: meta.data })
393
+ meta.data = this._validate({ collection, action: action, data: meta.data })
366
394
 
367
395
  await this.db.collection(collection).insertOne(func.toBson(meta.data, { col }) as any, {
368
396
  session: this.session,
@@ -400,7 +428,7 @@ class MongoRest {
400
428
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
401
429
  }
402
430
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
403
- this._validate({ collection, action: action, data: meta.data })
431
+ meta.data = this._validate({ collection, action: action, data: meta.data })
404
432
 
405
433
  await this.db.collection(collection).insertMany(func.toBson(meta.data, { col }) as any, {
406
434
  session: this.session,
@@ -429,15 +457,13 @@ class MongoRest {
429
457
  }
430
458
 
431
459
  update = await func.buildInput(update)
460
+ this._validate({ collection, action: action, update: update })
461
+ update = func.toBson(update, { col })
432
462
 
433
463
  const meta: any = { action, collection, update, id: _id }
434
464
  if (col.hooks?.beforeOperation) {
435
465
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
436
466
  }
437
- // Validate and toBson AFTER hooks
438
- update = meta.update;
439
- this._validate({ collection, action: action, update: update })
440
- update = func.toBson(update, { col })
441
467
 
442
468
  const data = await this.db.collection(collection).findOneAndUpdate(
443
469
  { _id: new ObjectId(_id) },
@@ -493,18 +519,16 @@ class MongoRest {
493
519
  }
494
520
 
495
521
 
496
- const meta: any = { action, collection, filter, update }
497
- if (col.hooks?.beforeOperation) {
498
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
499
- }
500
- // Validate and toBson AFTER hooks
501
- update = meta.update;
502
- filter = meta.filter;
503
522
  this._validate({ collection, action: action, update: update.$set })
504
523
  this._validate({ collection, action: action, update: update.$setOnInsert })
505
524
  update = func.toBson(update, { col })
506
525
  filter = func.toBson(filter, { col })
507
526
 
527
+ const meta: any = { action, collection, filter, update }
528
+ if (col.hooks?.beforeOperation) {
529
+ await col.hooks.beforeOperation({ rest: this, io, action, meta })
530
+ }
531
+
508
532
  const data = await this.db.collection(collection).findOneAndUpdate(
509
533
  filter,
510
534
  {
@@ -536,15 +560,13 @@ class MongoRest {
536
560
  update.$set = await func.buildInput(update.$set, { action: action, col: col })
537
561
  }
538
562
  update = await func.buildInput(update)
563
+ this._validate({ collection, action: action, update: update })
564
+ update = func.toBson(update, { col })
539
565
 
540
566
  const meta: any = { action, collection, update, ids: _ids }
541
567
  if (col.hooks?.beforeOperation) {
542
568
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
543
569
  }
544
- // Validate and toBson AFTER hooks
545
- update = meta.update;
546
- this._validate({ collection, action: action, update: update })
547
- update = func.toBson(update, { col })
548
570
 
549
571
  const result = await this.db.collection(collection).updateMany(
550
572
  { _id: { $in: _ids.map(id => new ObjectId(id)) } },
@@ -761,14 +783,14 @@ class MongoRest {
761
783
 
762
784
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
763
785
 
764
- return await col.actions?.[action]({
786
+ return await col.actions?.[action]?.({
765
787
  rest: this,
766
- io,
788
+ io,
767
789
  data,
768
790
  error: fn.error,
769
791
  jwt: func.jwt,
770
- token: token ?? { value: null, decoded: null },
771
- })
792
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
793
+ } as any)
772
794
  }
773
795
 
774
796
  async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
@@ -785,15 +807,14 @@ class MongoRest {
785
807
 
786
808
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
787
809
 
788
- return await serviceInstance.actions?.[action]({
810
+ return await serviceInstance.actions?.[action]?.({
789
811
  data,
790
812
  error: fn.error,
791
813
  io,
792
814
  jwt: func.jwt,
793
815
  token: token ?? { value: null, decoded: null, provided: false, expired: false },
794
816
  rest: this,
795
- io,
796
- })
817
+ } as any)
797
818
  }
798
819
 
799
820
  async stats() {
@@ -910,8 +931,8 @@ class MongoRest {
910
931
 
911
932
  try {
912
933
  const result = await this.db.collection('_locks_').findOneAndUpdate(
913
- { _id: id, expiresAt: { $lt: now } },
914
- { $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 } },
915
936
  { upsert: true, returnDocument: 'after' }
916
937
  );
917
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.19",
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,7 @@ 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';
20
21
 
21
22
  /** Check if a TCP port is already bound */
22
23
  async function isPortInUse(port: number): Promise<boolean> {
@@ -55,9 +56,8 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
55
56
  const hasAuth = cfg.collections?.some((c: any) => c.api?.auth?.enabled);
56
57
  let jwtSecret = cfg.server.jwt?.secret || Bun.env.JWT_SECRET;
57
58
  if (hasAuth && !jwtSecret) {
58
- jwtSecret = Array.from({ length: 64 }, () =>
59
- 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'[Math.floor(Math.random() * 64)]
60
- ).join('');
59
+ // CSPRNG: crypto.randomBytes (not Math.random — predictable)
60
+ jwtSecret = crypto.randomBytes(48).toString('base64url');
61
61
  try {
62
62
  const envPath = '.env';
63
63
  const exists = await Bun.file(envPath).exists();
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
 
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 {
@@ -645,12 +692,34 @@ function buildPipeline(p: FindOptions, options?: {
645
692
  status: 400
646
693
  });
647
694
  }
695
+ // Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
696
+ boundMatchOperators(p.$match);
648
697
  pipeline.push({
649
698
  $match: p.$match
650
699
  })
651
700
  }
652
701
 
653
- // $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
+ }
654
723
  if (!p?.$sort?.createdAt) {
655
724
  pipeline.push({
656
725
  $sort: {
@@ -664,20 +733,25 @@ function buildPipeline(p: FindOptions, options?: {
664
733
  })
665
734
  }
666
735
 
667
- // $skip
736
+ // $skip (validated above)
668
737
  if (p?.$skip) {
669
738
  pipeline.push({
670
739
  $skip: p.$skip
671
740
  })
672
741
  }
673
742
 
674
- // $limit
743
+ // $limit — Faille 14: must be a safe integer in (0, MAX_LIMIT]; 0 would mean "no limit"
744
+ const MAX_LIMIT = 1000;
675
745
  p.$limit = p?.$limit ?? 100
676
- if (p?.$limit > 0) {
677
- pipeline.push({
678
- $limit: p.$limit
679
- })
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
+ });
680
751
  }
752
+ pipeline.push({
753
+ $limit: p.$limit
754
+ })
681
755
 
682
756
  // $include
683
757
  if (p?.$include && Array.isArray(p?.$include)) {
@@ -742,6 +816,17 @@ function buildPipeline(p: FindOptions, options?: {
742
816
 
743
817
  let as = include.as || include.localField
744
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
+
745
830
  pipeline.push({
746
831
  $lookup: {
747
832
  from: include.from,
@@ -807,6 +892,46 @@ function clone<T>(data: T): T {
807
892
  return data_
808
893
  }
809
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
+
810
935
 
811
936
 
812
937
 
@@ -1007,14 +1132,18 @@ function countAggregatePipelineStages(pipeline: unknown): number {
1007
1132
  }
1008
1133
 
1009
1134
 
1010
- function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1135
+ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allowedCollections?: string[]): {
1011
1136
  isSafe: boolean;
1012
1137
  forbiddenKeys: string[];
1013
1138
  error: AppError | null;
1014
1139
  } {
1015
1140
 
1016
- // Unauthorized pipeline keys
1017
- 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
+ ];
1018
1147
  const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
1019
1148
  if (forbiddenFound.length > 0) {
1020
1149
  return {
@@ -1024,6 +1153,28 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
1024
1153
  }
1025
1154
  }
1026
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
+
1027
1178
  // $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
1028
1179
  let renameForbiddenKeys: string[] = [];
1029
1180
  let renameError: AppError | null = null;
@@ -1066,6 +1217,7 @@ export {
1066
1217
  reorder,
1067
1218
  omit,
1068
1219
  pick,
1220
+ unflattenKeys,
1069
1221
  toJson,
1070
1222
  stringToBoolean,
1071
1223
  cleanDeep,