@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.
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +62 -41
- package/lib/files.ts +100 -5
- package/package.json +2 -2
- package/server/api.ts +31 -27
- package/server/boot.ts +3 -3
- package/server/hono.ts +8 -5
- package/tests/api.test.ts +14 -12
- package/tests/ts1011-test.ts +15 -0
- package/types/api.d.ts +11 -0
- package/utils/func.ts +166 -14
package/database/decorator.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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]
|
|
106
|
-
expect(docs[1]
|
|
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(
|
|
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:
|
|
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
|
|
677
|
-
|
|
678
|
-
|
|
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 = [
|
|
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,
|