@anteros/core 0.0.18 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/database/collection.ts +39 -11
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +80 -67
- package/lib/files.ts +100 -5
- package/package.json +2 -2
- package/server/api.ts +31 -27
- package/server/boot.ts +48 -1
- 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/types/collection.d.ts +2 -0
- package/types/config.d.ts +1 -0
- package/utils/func.ts +179 -16
package/database/collection.ts
CHANGED
|
@@ -84,23 +84,35 @@ async function initializeOnDatabase(collections: Collection[]) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
if (field?.index) {
|
|
87
|
-
let indexValue:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
indexValue =
|
|
87
|
+
let indexValue: number | string = 1;
|
|
88
|
+
|
|
89
|
+
// Honor explicit indexType first
|
|
90
|
+
if (field.indexType) {
|
|
91
|
+
indexValue = field.indexType;
|
|
92
|
+
} else if (typeof field.index === 'number') {
|
|
93
|
+
indexValue = field.index;
|
|
94
|
+
} else if (field.type === 'geojson.Point' || field.type === 'geojson.LineString' || field.type === 'geojson.Polygon') {
|
|
95
|
+
indexValue = '2dsphere';
|
|
96
|
+
} else {
|
|
97
|
+
let includesStringType: Field['type'][] = ['string', 'email', 'enum', 'url', 'random', 'slug', 'relationship', 'uuid', 'password'];
|
|
98
|
+
let includesDateType: Field['type'][] = ['date', 'datetime-local'];
|
|
99
|
+
if (includesStringType.includes(field.type)) {
|
|
100
|
+
indexValue = 1;
|
|
101
|
+
} else if (includesDateType.includes(field.type)) {
|
|
102
|
+
indexValue = -1;
|
|
103
|
+
}
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
let isSparse = field.type === 'random'
|
|
107
|
+
|| (field.unique && field.required !== true);
|
|
108
|
+
|
|
97
109
|
specsFieldsIndexes.push({
|
|
98
110
|
key: {
|
|
99
|
-
[field.name]: indexValue as
|
|
111
|
+
[field.name]: indexValue as any
|
|
100
112
|
},
|
|
101
113
|
unique: field.unique ?? false,
|
|
102
|
-
sparse:
|
|
103
|
-
...field.indexOptions,
|
|
114
|
+
sparse: isSparse,
|
|
115
|
+
...field.indexOptions, // explicit overrides win
|
|
104
116
|
})
|
|
105
117
|
|
|
106
118
|
}
|
|
@@ -111,6 +123,22 @@ async function initializeOnDatabase(collections: Collection[]) {
|
|
|
111
123
|
if (specsFieldsIndexes.length) {
|
|
112
124
|
await db.collection(collection.slug).createIndexes(specsFieldsIndexes)
|
|
113
125
|
}
|
|
126
|
+
|
|
127
|
+
// Purge orphan indexes (fields removed from schema)
|
|
128
|
+
if (collection.purgeOrphanIndexes) {
|
|
129
|
+
const expectedKeys = new Set(specsFieldsIndexes.map((idx) =>
|
|
130
|
+
JSON.stringify(Object.keys(idx.key!).sort())
|
|
131
|
+
));
|
|
132
|
+
const existingIndexes = await db.collection(collection.slug).listIndexes().toArray();
|
|
133
|
+
for (const idx of existingIndexes) {
|
|
134
|
+
if (idx.name === '_id_') continue; // built-in
|
|
135
|
+
const idxKeys = JSON.stringify(Object.keys(idx.key!).sort());
|
|
136
|
+
if (!expectedKeys.has(idxKeys)) {
|
|
137
|
+
await db.collection(collection.slug).dropIndex(idx.name!);
|
|
138
|
+
console.log(`🧹 Dropped orphan index: ${collection.slug}.${idx.name}`.gray);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
114
142
|
}
|
|
115
143
|
|
|
116
144
|
} catch (err: any) {
|
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_
|
|
@@ -72,82 +76,81 @@ class MongoRest {
|
|
|
72
76
|
})
|
|
73
77
|
|
|
74
78
|
|
|
75
|
-
//let unauthorizedKeys = func.unauthorizedKeys(options.data, getCollectionKeys(col))
|
|
76
|
-
/* if (unauthorizedKeys.length > 0) {
|
|
77
|
-
throw new AppError(`Unauthorized keys in "${options.collection}" : ${unauthorizedKeys.join(', ')}`, {
|
|
78
|
-
code: 'UNAUTHORIZED_KEYS',
|
|
79
|
-
status: 400
|
|
80
|
-
})
|
|
81
|
-
} */
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
79
|
if (schema && partialSchema) {
|
|
86
80
|
|
|
87
81
|
|
|
88
82
|
if (options?.data) {
|
|
89
83
|
if (options.action == 'insertMany') {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (validationResult && validationResult.error) {
|
|
84
|
+
const sanitized: any[] = [];
|
|
85
|
+
for (let i = 0; i < options.data.length; i++) {
|
|
86
|
+
validationResult = schema.validate(options.data[i], { allowUnknown: false })
|
|
87
|
+
if (validationResult?.error) {
|
|
95
88
|
throw new AppError(validationResult.error.message, {
|
|
96
89
|
code: 'VALIDATION_ERROR',
|
|
97
90
|
status: 400
|
|
98
91
|
})
|
|
99
92
|
}
|
|
100
|
-
|
|
93
|
+
sanitized.push(validationResult.value)
|
|
94
|
+
}
|
|
95
|
+
return sanitized;
|
|
101
96
|
}
|
|
102
97
|
|
|
103
98
|
if (options.action == 'insertOne') {
|
|
104
|
-
validationResult = schema.validate(options.data, {
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
validationResult = schema.validate(options.data, { allowUnknown: false })
|
|
100
|
+
if (validationResult?.error) {
|
|
101
|
+
throw new AppError(validationResult.error.message, {
|
|
102
|
+
code: 'VALIDATION_ERROR',
|
|
103
|
+
status: 400
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
// Return sanitized value (Joi defaults applied, unknown keys handled)
|
|
107
|
+
return validationResult.value;
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
if (options?.update) {
|
|
112
|
-
if (options.action == 'insertOne' && options.update?.$set) {
|
|
113
|
-
validationResult = partialSchema.validate(options.update
|
|
114
|
-
allowUnknown: false,
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (options.action == 'insertMany' && options.update?.$set) {
|
|
119
|
-
validationResult = partialSchema.validate(options.update?.$set, {
|
|
120
|
-
allowUnknown: false,
|
|
121
|
-
})
|
|
113
|
+
if ((options.action == 'insertOne' || options.action == 'insertMany') && options.update?.$set) {
|
|
114
|
+
validationResult = partialSchema.validate(options.update.$set, { allowUnknown: false })
|
|
122
115
|
}
|
|
123
116
|
|
|
124
117
|
if (options.action == "findOneAndUpdate" && options.update.$set) {
|
|
125
|
-
validationResult = partialSchema.validate(options.update.$set, {
|
|
126
|
-
allowUnknown: false,
|
|
127
|
-
})
|
|
118
|
+
validationResult = partialSchema.validate(options.update.$set, { allowUnknown: false })
|
|
128
119
|
}
|
|
129
120
|
|
|
130
121
|
if (options.action == "findOneAndUpdate" && options.update.$setOnInsert) {
|
|
131
|
-
validationResult = partialSchema.validate(options.update.$setOnInsert, {
|
|
132
|
-
allowUnknown: false,
|
|
133
|
-
})
|
|
122
|
+
validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
|
|
134
123
|
}
|
|
135
|
-
}
|
|
136
124
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
125
|
+
// Faille 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
|
|
126
|
+
if ((options.action == 'updateOne' || options.action == 'updateMany') && options.update?.$set) {
|
|
127
|
+
const unflattened = func.unflattenKeys(options.update.$set)
|
|
128
|
+
validationResult = partialSchema.validate(unflattened, { allowUnknown: false })
|
|
129
|
+
if (validationResult?.error) {
|
|
130
|
+
throw new AppError(validationResult.error.message, {
|
|
131
|
+
code: 'VALIDATION_ERROR',
|
|
132
|
+
status: 400
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
// Write sanitized (nested) value back — MongoDB accepts nested objects
|
|
136
|
+
if (validationResult.value) {
|
|
137
|
+
options.update.$set = validationResult.value
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (validationResult?.error) {
|
|
142
|
+
throw new AppError(validationResult.error.message, {
|
|
143
|
+
code: 'VALIDATION_ERROR',
|
|
144
|
+
status: 400
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return validationResult?.value;
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
async #logActivity(data: {
|
|
151
154
|
action: string,
|
|
152
155
|
collection: string,
|
|
153
156
|
input?: any,
|
|
@@ -204,7 +207,7 @@ class MongoRest {
|
|
|
204
207
|
await this.audit.addActivities([activity])
|
|
205
208
|
}
|
|
206
209
|
|
|
207
|
-
|
|
210
|
+
async #executeWithAudit<T>(
|
|
208
211
|
action: string,
|
|
209
212
|
collection: string,
|
|
210
213
|
input: any,
|
|
@@ -269,7 +272,17 @@ class MongoRest {
|
|
|
269
272
|
try {
|
|
270
273
|
|
|
271
274
|
const col = getCollection(collection, this.#tenant.id) as Collection
|
|
272
|
-
|
|
275
|
+
// Faille 12: restrict $lookup targets to this tenant's declared collections
|
|
276
|
+
const allowedCollections = [
|
|
277
|
+
...(cfg.collections ?? [])
|
|
278
|
+
.filter(c => c._tenant_ === this.tenant_id)
|
|
279
|
+
.map(c => c.slug),
|
|
280
|
+
...(cfg.fileCollections ?? [])
|
|
281
|
+
.filter(fc => fc._tenant_ === this.tenant_id)
|
|
282
|
+
.map(fc => fc.slug),
|
|
283
|
+
'_activities_', '_locks_', '_workflows_',
|
|
284
|
+
];
|
|
285
|
+
const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
|
|
273
286
|
if (!isSafe.isSafe) {
|
|
274
287
|
throw isSafe.error;
|
|
275
288
|
}
|
|
@@ -371,18 +384,19 @@ class MongoRest {
|
|
|
371
384
|
data = await func.buildInput(data as T, {
|
|
372
385
|
action: action, col: col, rest: this
|
|
373
386
|
})
|
|
374
|
-
this._validate({ collection, action: action, data: data })
|
|
375
387
|
|
|
376
388
|
const meta: any = { action, collection, data }
|
|
377
389
|
if (col.hooks?.beforeOperation) {
|
|
378
390
|
await col.hooks.beforeOperation({ rest: this, io, action, meta })
|
|
379
391
|
}
|
|
392
|
+
// Validate AFTER hooks (they may fill required fields or apply Joi defaults)
|
|
393
|
+
meta.data = this._validate({ collection, action: action, data: meta.data })
|
|
380
394
|
|
|
381
|
-
await this.db.collection(collection).insertOne(func.toBson(data, { col }) as any, {
|
|
395
|
+
await this.db.collection(collection).insertOne(func.toBson(meta.data, { col }) as any, {
|
|
382
396
|
session: this.session,
|
|
383
397
|
})
|
|
384
398
|
|
|
385
|
-
const result: T & { _id: string } = func.toJson(data as T & { _id: string })
|
|
399
|
+
const result: T & { _id: string } = func.toJson(meta.data as T & { _id: string })
|
|
386
400
|
meta.result = result
|
|
387
401
|
if (col?.hooks?.afterOperation) {
|
|
388
402
|
await col.hooks.afterOperation({ rest: this, io, action, meta })
|
|
@@ -409,18 +423,18 @@ class MongoRest {
|
|
|
409
423
|
dataInput.push(d)
|
|
410
424
|
}
|
|
411
425
|
|
|
412
|
-
this._validate({ collection, action: action, data: dataInput })
|
|
413
|
-
|
|
414
426
|
const meta: any = { action, collection, data: dataInput }
|
|
415
427
|
if (col.hooks?.beforeOperation) {
|
|
416
428
|
await col.hooks.beforeOperation({ rest: this, io, action, meta })
|
|
417
429
|
}
|
|
430
|
+
// Validate AFTER hooks (they may fill required fields or apply Joi defaults)
|
|
431
|
+
meta.data = this._validate({ collection, action: action, data: meta.data })
|
|
418
432
|
|
|
419
|
-
await this.db.collection(collection).insertMany(func.toBson(
|
|
433
|
+
await this.db.collection(collection).insertMany(func.toBson(meta.data, { col }) as any, {
|
|
420
434
|
session: this.session,
|
|
421
435
|
})
|
|
422
436
|
|
|
423
|
-
const result: (T & { _id: string })[] = func.toJson(
|
|
437
|
+
const result: (T & { _id: string })[] = func.toJson(meta.data as (T & { _id: string })[])
|
|
424
438
|
meta.result = result
|
|
425
439
|
|
|
426
440
|
if (col.hooks?.afterOperation) {
|
|
@@ -769,14 +783,14 @@ class MongoRest {
|
|
|
769
783
|
|
|
770
784
|
const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
|
|
771
785
|
|
|
772
|
-
return await col.actions?.[action]({
|
|
786
|
+
return await col.actions?.[action]?.({
|
|
773
787
|
rest: this,
|
|
774
|
-
|
|
788
|
+
io,
|
|
775
789
|
data,
|
|
776
790
|
error: fn.error,
|
|
777
791
|
jwt: func.jwt,
|
|
778
|
-
token: token ?? { value: null, decoded: null },
|
|
779
|
-
})
|
|
792
|
+
token: token ?? { value: null, decoded: null, provided: false, expired: false },
|
|
793
|
+
} as any)
|
|
780
794
|
}
|
|
781
795
|
|
|
782
796
|
async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
|
|
@@ -793,15 +807,14 @@ class MongoRest {
|
|
|
793
807
|
|
|
794
808
|
const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
|
|
795
809
|
|
|
796
|
-
return await serviceInstance.actions?.[action]({
|
|
810
|
+
return await serviceInstance.actions?.[action]?.({
|
|
797
811
|
data,
|
|
798
812
|
error: fn.error,
|
|
799
813
|
io,
|
|
800
814
|
jwt: func.jwt,
|
|
801
815
|
token: token ?? { value: null, decoded: null, provided: false, expired: false },
|
|
802
816
|
rest: this,
|
|
803
|
-
|
|
804
|
-
})
|
|
817
|
+
} as any)
|
|
805
818
|
}
|
|
806
819
|
|
|
807
820
|
async stats() {
|
|
@@ -918,8 +931,8 @@ class MongoRest {
|
|
|
918
931
|
|
|
919
932
|
try {
|
|
920
933
|
const result = await this.db.collection('_locks_').findOneAndUpdate(
|
|
921
|
-
{ _id: id, expiresAt: { $lt: now } },
|
|
922
|
-
{ $set: {
|
|
934
|
+
{ _id: id as any, expiresAt: { $lt: now } },
|
|
935
|
+
{ $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
|
|
923
936
|
{ upsert: true, returnDocument: 'after' }
|
|
924
937
|
);
|
|
925
938
|
if (!result) {
|
package/lib/files.ts
CHANGED
|
@@ -53,7 +53,8 @@ export function createDiskStorage(baseDir?: string): FileStorage {
|
|
|
53
53
|
const filename = `${meta.id}${ext}`;
|
|
54
54
|
const filepath = path.join(dir, filename);
|
|
55
55
|
const buffer = await file.arrayBuffer();
|
|
56
|
-
|
|
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,20 @@ import { loadServices } from '../lib/services'
|
|
|
17
17
|
import { loadSockets } from '../lib/sockets'
|
|
18
18
|
import { syncWorkflows } from '../lib/workflow'
|
|
19
19
|
import { loadTenantsMiddlewares } from '../lib/middleware'
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
/** Check if a TCP port is already bound */
|
|
23
|
+
async function isPortInUse(port: number): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
const proc = Bun.spawn(['lsof', '-ti', `:${port}`], { stdout: 'pipe' });
|
|
26
|
+
const output = await new Response(proc.stdout).text();
|
|
27
|
+
await proc.exited;
|
|
28
|
+
return output.trim().length > 0;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
type BootAppOptions = ServerConfig & {
|
|
21
35
|
|
|
22
36
|
}
|
|
@@ -38,17 +52,50 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
38
52
|
//******************************* */
|
|
39
53
|
|
|
40
54
|
|
|
55
|
+
// JWT_SECRET check: auto-generate if missing and auth is enabled
|
|
56
|
+
const hasAuth = cfg.collections?.some((c: any) => c.api?.auth?.enabled);
|
|
57
|
+
let jwtSecret = cfg.server.jwt?.secret || Bun.env.JWT_SECRET;
|
|
58
|
+
if (hasAuth && !jwtSecret) {
|
|
59
|
+
// CSPRNG: crypto.randomBytes (not Math.random — predictable)
|
|
60
|
+
jwtSecret = crypto.randomBytes(48).toString('base64url');
|
|
61
|
+
try {
|
|
62
|
+
const envPath = '.env';
|
|
63
|
+
const exists = await Bun.file(envPath).exists();
|
|
64
|
+
const content = exists ? await Bun.file(envPath).text() : '';
|
|
65
|
+
if (!content.includes('JWT_SECRET=')) {
|
|
66
|
+
const suffix = content ? '\n' : '';
|
|
67
|
+
await Bun.write(envPath, `${content}${suffix}# Auto-generated on boot\nJWT_SECRET=${jwtSecret}\n`);
|
|
68
|
+
}
|
|
69
|
+
Bun.env.JWT_SECRET = jwtSecret;
|
|
70
|
+
console.log('🔐 JWT_SECRET auto-generated and saved to .env'.gray);
|
|
71
|
+
} catch {
|
|
72
|
+
Bun.env.JWT_SECRET = jwtSecret;
|
|
73
|
+
console.log('⚠ JWT_SECRET auto-generated (in-memory only, could not write .env)'.yellow);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
const PORT = (cfg.server.port || 4000);
|
|
42
78
|
const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
|
|
43
79
|
const useClusterMode = cfg.clusterMode || false;
|
|
44
80
|
const env = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
|
|
81
|
+
// reusePort: cluster mode ON by default, dev mode OFF to detect zombies
|
|
82
|
+
const reusePort = cfg.server.reusePort ?? (useClusterMode && env !== 'dev');
|
|
83
|
+
|
|
84
|
+
// Detect zombie: check if port is already in use in dev mode
|
|
85
|
+
if (!reusePort && env === 'dev') {
|
|
86
|
+
const occupied = await isPortInUse(PORT);
|
|
87
|
+
if (occupied) {
|
|
88
|
+
console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n → Or set server.reusePort: true in config\n`.red.bold);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
45
92
|
|
|
46
93
|
|
|
47
94
|
const app = createApp();
|
|
48
95
|
|
|
49
96
|
const server = Bun.serve({
|
|
50
97
|
port: PORT,
|
|
51
|
-
reusePort
|
|
98
|
+
reusePort,
|
|
52
99
|
fetch: (req, server) => {
|
|
53
100
|
const url = new URL(req.url);
|
|
54
101
|
|
package/server/hono.ts
CHANGED
|
@@ -114,11 +114,15 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
const requestCtx: Record<string, string> = c.req.header();
|
|
118
|
+
// Faille 1: never log sensitive headers (JWT, cookies)
|
|
119
|
+
delete requestCtx.authorization;
|
|
120
|
+
delete requestCtx.cookie;
|
|
117
121
|
requestCtxStorage.set('meta', {
|
|
118
122
|
request: {
|
|
119
123
|
ip: connInfo.remote.address ?? c.req.header('CF-Connecting-IP') ?? c.req.header('True-Client-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown',
|
|
120
124
|
user_agent: c.req.header('User-Agent') ?? '',
|
|
121
|
-
headers:
|
|
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/types/collection.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export type Collection = {
|
|
|
70
70
|
*/
|
|
71
71
|
_tenant_?: string;
|
|
72
72
|
_isTimeSerie_?: boolean;
|
|
73
|
+
/** Drop MongoDB indexes for fields no longer in the schema (default: false) */
|
|
74
|
+
purgeOrphanIndexes?: boolean;
|
|
73
75
|
_isCollection_?: boolean;
|
|
74
76
|
_schema_?: Joi.Schema;
|
|
75
77
|
_schemaPartial_?: Joi.Schema;
|
package/types/config.d.ts
CHANGED
package/utils/func.ts
CHANGED
|
@@ -36,7 +36,7 @@ const jwt = {
|
|
|
36
36
|
const token = await signer.sign(secret);
|
|
37
37
|
return token;
|
|
38
38
|
},
|
|
39
|
-
verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
|
|
39
|
+
verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null, expired: boolean }> => {
|
|
40
40
|
try {
|
|
41
41
|
if (!token) throw new Error('Token is required');
|
|
42
42
|
let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
@@ -45,12 +45,17 @@ const jwt = {
|
|
|
45
45
|
const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
|
|
46
46
|
return {
|
|
47
47
|
value: payload,
|
|
48
|
-
error: null
|
|
48
|
+
error: null,
|
|
49
|
+
expired: false,
|
|
49
50
|
}
|
|
50
|
-
} catch (error) {
|
|
51
|
+
} catch (error: any) {
|
|
52
|
+
// Reliable expiry detection via jose error code (not string matching)
|
|
53
|
+
const isExpired = error?.code === 'ERR_JWT_EXPIRED'
|
|
54
|
+
|| /expired|expiration/i.test(error?.message ?? '');
|
|
51
55
|
return {
|
|
52
56
|
value: null,
|
|
53
|
-
error: error instanceof Error ? error.message : 'Invalid token'
|
|
57
|
+
error: error instanceof Error ? error.message : 'Invalid token',
|
|
58
|
+
expired: isExpired,
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -152,6 +157,48 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
|
|
|
152
157
|
return result as Partial<T>;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Convertit la dot-notation MongoDB en structure imbriquée pour la validation Joi.
|
|
162
|
+
* Ex: { "address.zip": "BP28", "items.0.name": "x" } → { address: { zip }, items: [{ name }] }
|
|
163
|
+
* Les clés opérateurs ($inc, $push…) sont laissées telles quelles.
|
|
164
|
+
*/
|
|
165
|
+
function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
|
|
166
|
+
const result: Record<string, unknown> = {};
|
|
167
|
+
for (const [key, value] of Object.entries(input)) {
|
|
168
|
+
// Opérateurs Mongo → laisser tel quel (interdits dans $set de toute façon)
|
|
169
|
+
if (key.startsWith('$')) {
|
|
170
|
+
result[key] = value;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const parts = key.split('.');
|
|
174
|
+
let target: any = result;
|
|
175
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
176
|
+
const part = parts[i]!;
|
|
177
|
+
const nextPart = parts[i + 1]!;
|
|
178
|
+
const isArrayIndex = /^\d+$/.test(nextPart);
|
|
179
|
+
if (typeof target[part] !== 'object' || target[part] === null) {
|
|
180
|
+
target[part] = isArrayIndex ? [] : {};
|
|
181
|
+
}
|
|
182
|
+
target = target[part];
|
|
183
|
+
}
|
|
184
|
+
const last = parts[parts.length - 1]!;
|
|
185
|
+
// Fusion si les deux côtés sont des objets (ex. "a.b" + a: { c })
|
|
186
|
+
if (
|
|
187
|
+
target[last] &&
|
|
188
|
+
typeof target[last] === 'object' &&
|
|
189
|
+
!Array.isArray(target[last]) &&
|
|
190
|
+
typeof value === 'object' &&
|
|
191
|
+
value !== null &&
|
|
192
|
+
!Array.isArray(value)
|
|
193
|
+
) {
|
|
194
|
+
target[last] = { ...(target[last] as object), ...(value as object) };
|
|
195
|
+
} else {
|
|
196
|
+
target[last] = value;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
155
202
|
function toJson<T>(data: any): T {
|
|
156
203
|
if (data === null || data === undefined) return data;
|
|
157
204
|
try {
|
|
@@ -372,7 +419,10 @@ function formatToObjectId<T>(value: T): FormatToObjectIdResult<T> {
|
|
|
372
419
|
return value as FormatToObjectIdResult<T>;
|
|
373
420
|
}
|
|
374
421
|
|
|
375
|
-
|
|
422
|
+
// Pass through primitives and Date instances untouched
|
|
423
|
+
if (value instanceof Date) {
|
|
424
|
+
return value as FormatToObjectIdResult<T>;
|
|
425
|
+
}
|
|
376
426
|
|
|
377
427
|
if (typeof value === 'string') {
|
|
378
428
|
|
|
@@ -608,7 +658,15 @@ function collectDateFieldPathsFromCollection(col: Collection): string[] {
|
|
|
608
658
|
|
|
609
659
|
function toBson<T>(data: any | any[], _options?: FormatInputOptions): T {
|
|
610
660
|
let data_ = formatToObjectId(data);
|
|
611
|
-
|
|
661
|
+
|
|
662
|
+
// Build omitKeys for fields that should NOT be date-converted (random, number, integer)
|
|
663
|
+
const omitKeys: string[] = [];
|
|
664
|
+
for (const f of _options?.col?.fields ?? []) {
|
|
665
|
+
if (f.type === 'random' || f.type === 'number' || f.type === 'integer') {
|
|
666
|
+
omitKeys.push(f.name);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
data_ = formatToDate(data_, { omitKeys: omitKeys.length > 0 ? omitKeys : undefined });
|
|
612
670
|
|
|
613
671
|
if (Array.isArray(data)) {
|
|
614
672
|
data.length = 0;
|
|
@@ -634,12 +692,34 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
634
692
|
status: 400
|
|
635
693
|
});
|
|
636
694
|
}
|
|
695
|
+
// Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
|
|
696
|
+
boundMatchOperators(p.$match);
|
|
637
697
|
pipeline.push({
|
|
638
698
|
$match: p.$match
|
|
639
699
|
})
|
|
640
700
|
}
|
|
641
701
|
|
|
642
|
-
// $sort
|
|
702
|
+
// $sort — Faille 14: values must be 1 or -1
|
|
703
|
+
if (p?.$sort) {
|
|
704
|
+
for (const [key, dir] of Object.entries(p.$sort)) {
|
|
705
|
+
if (dir !== 1 && dir !== -1) {
|
|
706
|
+
throw new AppError(`Invalid $sort direction for '${key}' (expected 1 or -1)`, {
|
|
707
|
+
code: 'INVALID_SORT_DIRECTION',
|
|
708
|
+
status: 400
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// $skip — Faille 14: must be a safe non-negative integer
|
|
715
|
+
if (p?.$skip !== undefined && p?.$skip !== null) {
|
|
716
|
+
if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
|
|
717
|
+
throw new AppError('Invalid $skip (expected a non-negative integer)', {
|
|
718
|
+
code: 'INVALID_SKIP',
|
|
719
|
+
status: 400
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
643
723
|
if (!p?.$sort?.createdAt) {
|
|
644
724
|
pipeline.push({
|
|
645
725
|
$sort: {
|
|
@@ -653,20 +733,25 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
653
733
|
})
|
|
654
734
|
}
|
|
655
735
|
|
|
656
|
-
// $skip
|
|
736
|
+
// $skip (validated above)
|
|
657
737
|
if (p?.$skip) {
|
|
658
738
|
pipeline.push({
|
|
659
739
|
$skip: p.$skip
|
|
660
740
|
})
|
|
661
741
|
}
|
|
662
742
|
|
|
663
|
-
// $limit
|
|
743
|
+
// $limit — Faille 14: must be a safe integer in (0, MAX_LIMIT]; 0 would mean "no limit"
|
|
744
|
+
const MAX_LIMIT = 1000;
|
|
664
745
|
p.$limit = p?.$limit ?? 100
|
|
665
|
-
if (p
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
746
|
+
if (!Number.isSafeInteger(p.$limit) || p.$limit <= 0 || p.$limit > MAX_LIMIT) {
|
|
747
|
+
throw new AppError(`Invalid $limit (expected an integer between 1 and ${MAX_LIMIT})`, {
|
|
748
|
+
code: 'INVALID_LIMIT',
|
|
749
|
+
status: 400
|
|
750
|
+
});
|
|
669
751
|
}
|
|
752
|
+
pipeline.push({
|
|
753
|
+
$limit: p.$limit
|
|
754
|
+
})
|
|
670
755
|
|
|
671
756
|
// $include
|
|
672
757
|
if (p?.$include && Array.isArray(p?.$include)) {
|
|
@@ -731,6 +816,17 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
731
816
|
|
|
732
817
|
let as = include.as || include.localField
|
|
733
818
|
|
|
819
|
+
// Faille 3: never trust client-supplied $lookup sub-pipeline — scan it
|
|
820
|
+
if (include.pipeline && Array.isArray(include.pipeline)) {
|
|
821
|
+
const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
|
|
822
|
+
if (!check.isSafe) {
|
|
823
|
+
throw check.error ?? new AppError('Unauthorized $include pipeline', {
|
|
824
|
+
code: 'UNAUTHORIZED_PIPELINE_KEYS',
|
|
825
|
+
status: 400
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
734
830
|
pipeline.push({
|
|
735
831
|
$lookup: {
|
|
736
832
|
from: include.from,
|
|
@@ -796,6 +892,46 @@ function clone<T>(data: T): T {
|
|
|
796
892
|
return data_
|
|
797
893
|
}
|
|
798
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Faille 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
|
|
897
|
+
* Walks nested objects/arrays ($and, $or, $nor, dotted fields).
|
|
898
|
+
*/
|
|
899
|
+
const MAX_REGEX_LENGTH = 1000;
|
|
900
|
+
const MAX_IN_ARRAY_SIZE = 10000;
|
|
901
|
+
|
|
902
|
+
function boundMatchOperators(node: unknown): void {
|
|
903
|
+
if (node === null || node === undefined) return;
|
|
904
|
+
|
|
905
|
+
if (Array.isArray(node)) {
|
|
906
|
+
for (const item of node) {
|
|
907
|
+
boundMatchOperators(item);
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (typeof node === 'object') {
|
|
913
|
+
const obj = node as Record<string, unknown>;
|
|
914
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
915
|
+
if (key === '$regex' && typeof value === 'string') {
|
|
916
|
+
if (value.length > MAX_REGEX_LENGTH) {
|
|
917
|
+
throw new AppError(`$regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`, {
|
|
918
|
+
code: 'REGEX_TOO_LONG',
|
|
919
|
+
status: 400
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
} else if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
|
|
923
|
+
if (value.length > MAX_IN_ARRAY_SIZE) {
|
|
924
|
+
throw new AppError(`$in/$nin too large (max ${MAX_IN_ARRAY_SIZE} items)`, {
|
|
925
|
+
code: 'ARRAY_OPERATOR_TOO_LARGE',
|
|
926
|
+
status: 400
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
boundMatchOperators(value);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
799
935
|
|
|
800
936
|
|
|
801
937
|
|
|
@@ -996,14 +1132,18 @@ function countAggregatePipelineStages(pipeline: unknown): number {
|
|
|
996
1132
|
}
|
|
997
1133
|
|
|
998
1134
|
|
|
999
|
-
function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown
|
|
1135
|
+
function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allowedCollections?: string[]): {
|
|
1000
1136
|
isSafe: boolean;
|
|
1001
1137
|
forbiddenKeys: string[];
|
|
1002
1138
|
error: AppError | null;
|
|
1003
1139
|
} {
|
|
1004
1140
|
|
|
1005
|
-
// Unauthorized pipeline keys
|
|
1006
|
-
const UNAUTHORIZED_PIPELINE_KEYS = [
|
|
1141
|
+
// Unauthorized pipeline keys — Faille 12: include cross-collection exfiltration stages
|
|
1142
|
+
const UNAUTHORIZED_PIPELINE_KEYS = [
|
|
1143
|
+
'$out', '$merge', '$function', '$where', '$accumulator',
|
|
1144
|
+
'$unionWith', '$collStats', '$indexStats', '$planCacheStats',
|
|
1145
|
+
'$currentOp', '$listLocalSessions', '$listSessions', '$changeStream',
|
|
1146
|
+
];
|
|
1007
1147
|
const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
|
|
1008
1148
|
if (forbiddenFound.length > 0) {
|
|
1009
1149
|
return {
|
|
@@ -1013,6 +1153,28 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
|
|
|
1013
1153
|
}
|
|
1014
1154
|
}
|
|
1015
1155
|
|
|
1156
|
+
// Faille 12: restrict $lookup / $graphLookup targets to declared tenant collections
|
|
1157
|
+
if (allowedCollections && allowedCollections.length > 0) {
|
|
1158
|
+
const badTargets: string[] = [];
|
|
1159
|
+
walkAggregatePipeline(pipeline, (stage) => {
|
|
1160
|
+
const lookup = (stage as any).$lookup;
|
|
1161
|
+
if (lookup?.from && !allowedCollections.includes(lookup.from)) {
|
|
1162
|
+
badTargets.push(lookup.from);
|
|
1163
|
+
}
|
|
1164
|
+
const graph = (stage as any).$graphLookup;
|
|
1165
|
+
if (graph?.from && !allowedCollections.includes(graph.from)) {
|
|
1166
|
+
badTargets.push(graph.from);
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
if (badTargets.length > 0) {
|
|
1170
|
+
return {
|
|
1171
|
+
isSafe: false,
|
|
1172
|
+
forbiddenKeys: badTargets,
|
|
1173
|
+
error: new AppError('Unauthorized $lookup target collection: ' + [...new Set(badTargets)].join(', '), { code: 'UNAUTHORIZED_LOOKUP_TARGET', status: 400 })
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1016
1178
|
// $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
|
|
1017
1179
|
let renameForbiddenKeys: string[] = [];
|
|
1018
1180
|
let renameError: AppError | null = null;
|
|
@@ -1055,6 +1217,7 @@ export {
|
|
|
1055
1217
|
reorder,
|
|
1056
1218
|
omit,
|
|
1057
1219
|
pick,
|
|
1220
|
+
unflattenKeys,
|
|
1058
1221
|
toJson,
|
|
1059
1222
|
stringToBoolean,
|
|
1060
1223
|
cleanDeep,
|