@anteros/core 0.0.19 → 0.0.21
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/README.md +90 -5
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +64 -40
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +115 -32
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +6 -3
- package/probe-cache.ts +16 -0
- package/server/api.ts +35 -28
- package/server/boot.ts +78 -16
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +192 -18
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/api.test.ts +14 -12
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/ts1011-test.ts +15 -0
- package/tests/utils.test.ts +113 -0
- package/types/api.d.ts +11 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- package/utils/func.ts +178 -18
package/types/hook.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { MongoRest } from "../database/mongodbadapter";
|
|
2
2
|
import type { Server as SocketIO } from "socket.io";
|
|
3
3
|
import type { ActionsApiList } from "./api";
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
export type HookMeta = {
|
|
5
6
|
action: ActionsApiList;
|
|
6
7
|
collection: string;
|
|
7
8
|
data?: any;
|
|
@@ -14,17 +15,19 @@ type metaHook = {
|
|
|
14
15
|
pipeline?: any[];
|
|
15
16
|
options?: any;
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
/** Context passed to a collection hook (`define.Hook`). */
|
|
20
|
+
export type HookContext = {
|
|
21
|
+
rest: MongoRest;
|
|
22
|
+
action: ActionsApiList;
|
|
23
|
+
meta: HookMeta;
|
|
24
|
+
io: SocketIO;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A collection hook — `define.Hook(fn)`, used as beforeOperation/afterOperation. */
|
|
28
|
+
export type CollectionHook = (ctx: HookContext) => Promise<void>;
|
|
29
|
+
|
|
17
30
|
export type HooksCollection = {
|
|
18
|
-
beforeOperation?:
|
|
19
|
-
|
|
20
|
-
action: ActionsApiList;
|
|
21
|
-
meta: metaHook;
|
|
22
|
-
io: SocketIO;
|
|
23
|
-
}) => Promise<void>;
|
|
24
|
-
afterOperation?: (ctx: {
|
|
25
|
-
rest: MongoRest;
|
|
26
|
-
action: ActionsApiList;
|
|
27
|
-
meta: metaHook;
|
|
28
|
-
io: SocketIO;
|
|
29
|
-
}) => Promise<void>;
|
|
31
|
+
beforeOperation?: CollectionHook;
|
|
32
|
+
afterOperation?: CollectionHook;
|
|
30
33
|
}
|
package/types/mcp.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type Joi from "joi";
|
|
2
|
+
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import type { useRest } from "../database/rest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MCP tool result — the Model Context Protocol `CallToolResult` shape.
|
|
8
|
+
* `exec` must return this (or a Promise of it).
|
|
9
|
+
*/
|
|
10
|
+
export type McpToolResult = {
|
|
11
|
+
content: ContentBlock[];
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
structuredContent?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MCP tool definition — auto-loaded from `{tenant.dir}/mcp/tools/**\/*.tool.ts`.
|
|
18
|
+
* Served over the MCP protocol at `GET/POST /mcp/:tenant_id` (Streamable HTTP).
|
|
19
|
+
*/
|
|
20
|
+
export type McpTool = {
|
|
21
|
+
_isMcpTool_?: boolean;
|
|
22
|
+
_tenant_?: string;
|
|
23
|
+
/** Tool name — unique per tenant (how the LLM calls it) */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Short description shown to the LLM */
|
|
26
|
+
description?: string;
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** Joi schema describing the tool arguments (same language as collections — `v`) */
|
|
29
|
+
inputSchema: Joi.Schema;
|
|
30
|
+
/** Handler executed when the LLM calls the tool (args are Joi-validated).
|
|
31
|
+
* Must return the MCP protocol result shape: `{ content: [{ type, text | data… }] }`. */
|
|
32
|
+
exec: (ctx: {
|
|
33
|
+
c: Context;
|
|
34
|
+
rest: InstanceType<typeof useRest>;
|
|
35
|
+
/** Parsed & validated arguments */
|
|
36
|
+
args: Record<string, any>;
|
|
37
|
+
}) => McpToolResult | Promise<McpToolResult>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* MCP resource read result — the protocol `ReadResourceResult` shape.
|
|
42
|
+
* `contents` is an array of text or blob (base64) content blocks.
|
|
43
|
+
*/
|
|
44
|
+
export type McpResourceResult = {
|
|
45
|
+
contents: Array<{
|
|
46
|
+
uri: string;
|
|
47
|
+
mimeType?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
blob?: string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* MCP resource — read-only data exposed by URI (like a file the LLM can open).
|
|
55
|
+
* Auto-loaded from `{tenant.dir}/mcp/resources/**\/*.resource.ts`.
|
|
56
|
+
*
|
|
57
|
+
* The `uri` can be static (`orders://summary`) or a template
|
|
58
|
+
* (`users://{id}`) — template params are extracted and passed to `read`.
|
|
59
|
+
*/
|
|
60
|
+
export type McpResource = {
|
|
61
|
+
_isMcpResource_?: boolean;
|
|
62
|
+
_tenant_?: string;
|
|
63
|
+
/** Resource name — unique per tenant */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Short description shown to the LLM */
|
|
66
|
+
description?: string;
|
|
67
|
+
enabled?: boolean;
|
|
68
|
+
/** Static URI (e.g. `orders://summary`) or template (e.g. `users://{id}`) */
|
|
69
|
+
uri: string;
|
|
70
|
+
/** Content type of the resource (default: application/json) */
|
|
71
|
+
mimeType?: string;
|
|
72
|
+
/** Handler called when the LLM reads the resource */
|
|
73
|
+
read: (ctx: {
|
|
74
|
+
c: Context;
|
|
75
|
+
rest: InstanceType<typeof useRest>;
|
|
76
|
+
/** Params extracted from the URI template (e.g. `{ id: '123' }`) */
|
|
77
|
+
params: Record<string, string>;
|
|
78
|
+
/** The exact URI the client requested */
|
|
79
|
+
uri: string;
|
|
80
|
+
}) => McpResourceResult | Promise<McpResourceResult>;
|
|
81
|
+
};
|
package/types/scripts.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { useRest } from "../database/rest"
|
|
2
|
+
import type { Tenant } from "./tenant"
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
export type Script = {
|
|
5
6
|
_isScript_?: boolean;
|
|
6
7
|
enabled: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Run in a separate Bun process so heavy work (large JSON, long imports…)
|
|
10
|
+
* never blocks the HTTP server. The subprocess reconnects the tenant DB and
|
|
11
|
+
* rebuilds collection schemas automatically.
|
|
12
|
+
*/
|
|
13
|
+
heavy?: boolean;
|
|
14
|
+
/** Max runtime in ms for heavy scripts (default: no timeout) */
|
|
15
|
+
timeout?: number;
|
|
7
16
|
exec: (ctx: {
|
|
8
17
|
rest: InstanceType<typeof useRest>
|
|
18
|
+
/** Report progress — only wired for heavy scripts (sent via IPC to the parent) */
|
|
19
|
+
progress?: (percent: number, info?: any) => void;
|
|
20
|
+
/** Tenant this script runs for */
|
|
21
|
+
tenant?: Tenant;
|
|
9
22
|
}) => void
|
|
10
|
-
}
|
|
23
|
+
}
|
package/utils/cache.ts
CHANGED
|
@@ -50,6 +50,25 @@ class Cache {
|
|
|
50
50
|
}
|
|
51
51
|
})
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
// ─── BentoCache provider API (delegated to the underlying instance) ───
|
|
55
|
+
|
|
56
|
+
set(options: any): Promise<boolean> { return this.#cache.set(options); }
|
|
57
|
+
setForever(options: any): Promise<boolean> { return this.#cache.setForever(options); }
|
|
58
|
+
get(options: any): Promise<unknown> { return this.#cache.get(options); }
|
|
59
|
+
getOrSet(options: any): Promise<unknown> { return this.#cache.getOrSet(options); }
|
|
60
|
+
getOrSetForever(options: any): Promise<unknown> { return this.#cache.getOrSetForever(options); }
|
|
61
|
+
has(options: any): Promise<boolean> { return this.#cache.has(options); }
|
|
62
|
+
missing(options: any): Promise<boolean> { return this.#cache.missing(options); }
|
|
63
|
+
pull(key: string): Promise<unknown> { return this.#cache.pull(key); }
|
|
64
|
+
delete(options: any): Promise<boolean> { return this.#cache.delete(options); }
|
|
65
|
+
deleteMany(options: any): Promise<boolean> { return this.#cache.deleteMany(options); }
|
|
66
|
+
deleteByTag(options: any): Promise<boolean> { return this.#cache.deleteByTag(options); }
|
|
67
|
+
expire(options: any): Promise<boolean> { return this.#cache.expire(options); }
|
|
68
|
+
clear(options?: any): Promise<void> { return this.#cache.clear(options); }
|
|
69
|
+
prune(): Promise<void> { return this.#cache.prune(); }
|
|
70
|
+
namespace(namespace: string): any { return this.#cache.namespace(namespace); }
|
|
71
|
+
disconnect(): Promise<void> { return this.#cache.disconnect(); }
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
|
|
@@ -93,4 +112,4 @@ export {
|
|
|
93
112
|
useFilesystemCache,
|
|
94
113
|
useMemoryCache,
|
|
95
114
|
useRedisCache
|
|
96
|
-
}
|
|
115
|
+
}
|
package/utils/crypto.ts
CHANGED
|
@@ -2,6 +2,20 @@ const IV_LENGTH = 12;
|
|
|
2
2
|
const TAG_LENGTH = 16;
|
|
3
3
|
const RSA_KEY_SIZE = 256;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Build AES-GCM parameters with optional Additional Authenticated Data.
|
|
7
|
+
* AAD binds the ciphertext to its context (field/record/tenant) so a valid
|
|
8
|
+
* ciphertext cannot be swapped into another context (substitution attack).
|
|
9
|
+
*/
|
|
10
|
+
function gcmParams(iv: Uint8Array, aad?: string) {
|
|
11
|
+
return {
|
|
12
|
+
name: "AES-GCM" as const,
|
|
13
|
+
iv,
|
|
14
|
+
tagLength: TAG_LENGTH * 8,
|
|
15
|
+
...(aad ? { additionalData: new TextEncoder().encode(aad) } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
interface JsonWebKey {
|
|
6
20
|
kty?: string;
|
|
7
21
|
alg?: string;
|
|
@@ -43,12 +57,12 @@ class CryptSym {
|
|
|
43
57
|
return this.#key;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
async encrypt(data: string | object): Promise<string> {
|
|
60
|
+
async encrypt(data: string | object, aad?: string): Promise<string> {
|
|
47
61
|
const text = typeof data === "object" ? JSON.stringify(data) : data;
|
|
48
62
|
const key = await this.#getKey();
|
|
49
63
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
50
64
|
const encrypted = await crypto.subtle.encrypt(
|
|
51
|
-
|
|
65
|
+
gcmParams(iv, aad),
|
|
52
66
|
key,
|
|
53
67
|
new TextEncoder().encode(text)
|
|
54
68
|
);
|
|
@@ -58,14 +72,17 @@ class CryptSym {
|
|
|
58
72
|
return Buffer.from(buffer).toString("base64");
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
|
|
75
|
+
async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
|
|
62
76
|
try {
|
|
63
77
|
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
78
|
+
if (buffer.length < IV_LENGTH + TAG_LENGTH) {
|
|
79
|
+
throw new Error("ciphertext too short");
|
|
80
|
+
}
|
|
64
81
|
const iv = buffer.slice(0, IV_LENGTH);
|
|
65
82
|
const data = buffer.slice(IV_LENGTH);
|
|
66
83
|
const key = await this.#getKey();
|
|
67
84
|
const decrypted = await crypto.subtle.decrypt(
|
|
68
|
-
|
|
85
|
+
gcmParams(iv, aad),
|
|
69
86
|
key,
|
|
70
87
|
data
|
|
71
88
|
);
|
|
@@ -131,7 +148,7 @@ class CryptAsym {
|
|
|
131
148
|
return this.#keyPair;
|
|
132
149
|
}
|
|
133
150
|
|
|
134
|
-
async encrypt(data: string | object, publicKey?: CryptoKey): Promise<string> {
|
|
151
|
+
async encrypt(data: string | object, publicKey?: CryptoKey, aad?: string): Promise<string> {
|
|
135
152
|
const text = typeof data === "object" ? JSON.stringify(data) : data;
|
|
136
153
|
const rsaKey = publicKey ?? (await this.#getKeyPair()).publicKey;
|
|
137
154
|
|
|
@@ -143,7 +160,7 @@ class CryptAsym {
|
|
|
143
160
|
|
|
144
161
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
145
162
|
const encrypted = await crypto.subtle.encrypt(
|
|
146
|
-
|
|
163
|
+
gcmParams(iv, aad),
|
|
147
164
|
aesKey,
|
|
148
165
|
new TextEncoder().encode(text)
|
|
149
166
|
);
|
|
@@ -162,7 +179,7 @@ class CryptAsym {
|
|
|
162
179
|
return Buffer.from(result).toString("base64");
|
|
163
180
|
}
|
|
164
181
|
|
|
165
|
-
async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
|
|
182
|
+
async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
|
|
166
183
|
try {
|
|
167
184
|
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
168
185
|
if (buffer.length < RSA_KEY_SIZE + IV_LENGTH + TAG_LENGTH) {
|
|
@@ -189,7 +206,7 @@ class CryptAsym {
|
|
|
189
206
|
);
|
|
190
207
|
|
|
191
208
|
const decrypted = await crypto.subtle.decrypt(
|
|
192
|
-
|
|
209
|
+
gcmParams(iv, aad),
|
|
193
210
|
aesKey,
|
|
194
211
|
ciphertext
|
|
195
212
|
);
|
package/utils/func.ts
CHANGED
|
@@ -19,15 +19,16 @@ const jwt = {
|
|
|
19
19
|
audience?: string | string[];
|
|
20
20
|
subject?: string;
|
|
21
21
|
}) => {
|
|
22
|
-
|
|
22
|
+
const SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
23
23
|
if (!SECRET_) throw new Error('JWT_SECRET is not set');
|
|
24
|
-
|
|
24
|
+
// Per-call expiresIn wins over the global config (default: 7d)
|
|
25
|
+
const EXPIRES_IN = options?.expiresIn || cfg.server.jwt?.expiresIn || '7d'
|
|
25
26
|
|
|
26
27
|
const secret = new TextEncoder().encode(SECRET_);
|
|
27
28
|
const signer = new Jose.SignJWT(payload)
|
|
28
29
|
.setProtectedHeader({ alg: 'HS256' })
|
|
29
30
|
.setIssuedAt()
|
|
30
|
-
.setExpirationTime(EXPIRES_IN
|
|
31
|
+
.setExpirationTime(EXPIRES_IN)
|
|
31
32
|
|
|
32
33
|
if (options?.issuer) signer.setIssuer(options.issuer)
|
|
33
34
|
if (options?.audience) signer.setAudience(options.audience)
|
|
@@ -36,7 +37,7 @@ const jwt = {
|
|
|
36
37
|
const token = await signer.sign(secret);
|
|
37
38
|
return token;
|
|
38
39
|
},
|
|
39
|
-
verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
|
|
40
|
+
verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null, expired: boolean }> => {
|
|
40
41
|
try {
|
|
41
42
|
if (!token) throw new Error('Token is required');
|
|
42
43
|
let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
@@ -45,12 +46,17 @@ const jwt = {
|
|
|
45
46
|
const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
|
|
46
47
|
return {
|
|
47
48
|
value: payload,
|
|
48
|
-
error: null
|
|
49
|
+
error: null,
|
|
50
|
+
expired: false,
|
|
49
51
|
}
|
|
50
|
-
} catch (error) {
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
// Reliable expiry detection via jose error code (not string matching)
|
|
54
|
+
const isExpired = error?.code === 'ERR_JWT_EXPIRED'
|
|
55
|
+
|| /expired|expiration/i.test(error?.message ?? '');
|
|
51
56
|
return {
|
|
52
57
|
value: null,
|
|
53
|
-
error: error instanceof Error ? error.message : 'Invalid token'
|
|
58
|
+
error: error instanceof Error ? error.message : 'Invalid token',
|
|
59
|
+
expired: isExpired,
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
}
|
|
@@ -152,6 +158,48 @@ function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> |
|
|
|
152
158
|
return result as Partial<T>;
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Convertit la dot-notation MongoDB en structure imbriquée pour la validation Joi.
|
|
163
|
+
* Ex: { "address.zip": "BP28", "items.0.name": "x" } → { address: { zip }, items: [{ name }] }
|
|
164
|
+
* Les clés opérateurs ($inc, $push…) sont laissées telles quelles.
|
|
165
|
+
*/
|
|
166
|
+
function unflattenKeys(input: Record<string, unknown>): Record<string, unknown> {
|
|
167
|
+
const result: Record<string, unknown> = {};
|
|
168
|
+
for (const [key, value] of Object.entries(input)) {
|
|
169
|
+
// Opérateurs Mongo → laisser tel quel (interdits dans $set de toute façon)
|
|
170
|
+
if (key.startsWith('$')) {
|
|
171
|
+
result[key] = value;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const parts = key.split('.');
|
|
175
|
+
let target: any = result;
|
|
176
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
177
|
+
const part = parts[i]!;
|
|
178
|
+
const nextPart = parts[i + 1]!;
|
|
179
|
+
const isArrayIndex = /^\d+$/.test(nextPart);
|
|
180
|
+
if (typeof target[part] !== 'object' || target[part] === null) {
|
|
181
|
+
target[part] = isArrayIndex ? [] : {};
|
|
182
|
+
}
|
|
183
|
+
target = target[part];
|
|
184
|
+
}
|
|
185
|
+
const last = parts[parts.length - 1]!;
|
|
186
|
+
// Fusion si les deux côtés sont des objets (ex. "a.b" + a: { c })
|
|
187
|
+
if (
|
|
188
|
+
target[last] &&
|
|
189
|
+
typeof target[last] === 'object' &&
|
|
190
|
+
!Array.isArray(target[last]) &&
|
|
191
|
+
typeof value === 'object' &&
|
|
192
|
+
value !== null &&
|
|
193
|
+
!Array.isArray(value)
|
|
194
|
+
) {
|
|
195
|
+
target[last] = { ...(target[last] as object), ...(value as object) };
|
|
196
|
+
} else {
|
|
197
|
+
target[last] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
155
203
|
function toJson<T>(data: any): T {
|
|
156
204
|
if (data === null || data === undefined) return data;
|
|
157
205
|
try {
|
|
@@ -254,7 +302,14 @@ function isEpochNumber(value: number): boolean {
|
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
function deepCopy<T>(value: T): T {
|
|
257
|
-
|
|
305
|
+
// structuredClone preserves Date / RegExp / Map / Set / typed arrays
|
|
306
|
+
// (clone() uses toJson, which JSON-serializes and loses those types).
|
|
307
|
+
// Falls back to clone() for values structuredClone cannot handle.
|
|
308
|
+
try {
|
|
309
|
+
return structuredClone(value);
|
|
310
|
+
} catch {
|
|
311
|
+
return clone(value);
|
|
312
|
+
}
|
|
258
313
|
}
|
|
259
314
|
|
|
260
315
|
type FormatToDateResult<T> = T extends string
|
|
@@ -645,12 +700,34 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
645
700
|
status: 400
|
|
646
701
|
});
|
|
647
702
|
}
|
|
703
|
+
// Faille 13: bound $regex (ReDoS) and $in/$nin (memory DoS)
|
|
704
|
+
boundMatchOperators(p.$match);
|
|
648
705
|
pipeline.push({
|
|
649
706
|
$match: p.$match
|
|
650
707
|
})
|
|
651
708
|
}
|
|
652
709
|
|
|
653
|
-
// $sort
|
|
710
|
+
// $sort — Faille 14: values must be 1 or -1
|
|
711
|
+
if (p?.$sort) {
|
|
712
|
+
for (const [key, dir] of Object.entries(p.$sort)) {
|
|
713
|
+
if (dir !== 1 && dir !== -1) {
|
|
714
|
+
throw new AppError(`Invalid $sort direction for '${key}' (expected 1 or -1)`, {
|
|
715
|
+
code: 'INVALID_SORT_DIRECTION',
|
|
716
|
+
status: 400
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// $skip — Faille 14: must be a safe non-negative integer
|
|
723
|
+
if (p?.$skip !== undefined && p?.$skip !== null) {
|
|
724
|
+
if (!Number.isSafeInteger(p.$skip) || p.$skip < 0) {
|
|
725
|
+
throw new AppError('Invalid $skip (expected a non-negative integer)', {
|
|
726
|
+
code: 'INVALID_SKIP',
|
|
727
|
+
status: 400
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
654
731
|
if (!p?.$sort?.createdAt) {
|
|
655
732
|
pipeline.push({
|
|
656
733
|
$sort: {
|
|
@@ -664,20 +741,25 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
664
741
|
})
|
|
665
742
|
}
|
|
666
743
|
|
|
667
|
-
// $skip
|
|
744
|
+
// $skip (validated above)
|
|
668
745
|
if (p?.$skip) {
|
|
669
746
|
pipeline.push({
|
|
670
747
|
$skip: p.$skip
|
|
671
748
|
})
|
|
672
749
|
}
|
|
673
750
|
|
|
674
|
-
// $limit
|
|
751
|
+
// $limit — Faille 14: must be a safe integer in (0, MAX_LIMIT]; 0 would mean "no limit"
|
|
752
|
+
const MAX_LIMIT = 1000;
|
|
675
753
|
p.$limit = p?.$limit ?? 100
|
|
676
|
-
if (p
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
754
|
+
if (!Number.isSafeInteger(p.$limit) || p.$limit <= 0 || p.$limit > MAX_LIMIT) {
|
|
755
|
+
throw new AppError(`Invalid $limit (expected an integer between 1 and ${MAX_LIMIT})`, {
|
|
756
|
+
code: 'INVALID_LIMIT',
|
|
757
|
+
status: 400
|
|
758
|
+
});
|
|
680
759
|
}
|
|
760
|
+
pipeline.push({
|
|
761
|
+
$limit: p.$limit
|
|
762
|
+
})
|
|
681
763
|
|
|
682
764
|
// $include
|
|
683
765
|
if (p?.$include && Array.isArray(p?.$include)) {
|
|
@@ -742,6 +824,17 @@ function buildPipeline(p: FindOptions, options?: {
|
|
|
742
824
|
|
|
743
825
|
let as = include.as || include.localField
|
|
744
826
|
|
|
827
|
+
// Faille 3: never trust client-supplied $lookup sub-pipeline — scan it
|
|
828
|
+
if (include.pipeline && Array.isArray(include.pipeline)) {
|
|
829
|
+
const check = isSafeAggregatePipeline(include.pipeline as Array<Record<string, unknown>>)
|
|
830
|
+
if (!check.isSafe) {
|
|
831
|
+
throw check.error ?? new AppError('Unauthorized $include pipeline', {
|
|
832
|
+
code: 'UNAUTHORIZED_PIPELINE_KEYS',
|
|
833
|
+
status: 400
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
745
838
|
pipeline.push({
|
|
746
839
|
$lookup: {
|
|
747
840
|
from: include.from,
|
|
@@ -807,6 +900,46 @@ function clone<T>(data: T): T {
|
|
|
807
900
|
return data_
|
|
808
901
|
}
|
|
809
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Faille 13: bounds dangerous $match operators to prevent ReDoS ($regex) and memory DoS ($in).
|
|
905
|
+
* Walks nested objects/arrays ($and, $or, $nor, dotted fields).
|
|
906
|
+
*/
|
|
907
|
+
const MAX_REGEX_LENGTH = 1000;
|
|
908
|
+
const MAX_IN_ARRAY_SIZE = 10000;
|
|
909
|
+
|
|
910
|
+
function boundMatchOperators(node: unknown): void {
|
|
911
|
+
if (node === null || node === undefined) return;
|
|
912
|
+
|
|
913
|
+
if (Array.isArray(node)) {
|
|
914
|
+
for (const item of node) {
|
|
915
|
+
boundMatchOperators(item);
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (typeof node === 'object') {
|
|
921
|
+
const obj = node as Record<string, unknown>;
|
|
922
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
923
|
+
if (key === '$regex' && typeof value === 'string') {
|
|
924
|
+
if (value.length > MAX_REGEX_LENGTH) {
|
|
925
|
+
throw new AppError(`$regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`, {
|
|
926
|
+
code: 'REGEX_TOO_LONG',
|
|
927
|
+
status: 400
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
} else if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
|
|
931
|
+
if (value.length > MAX_IN_ARRAY_SIZE) {
|
|
932
|
+
throw new AppError(`$in/$nin too large (max ${MAX_IN_ARRAY_SIZE} items)`, {
|
|
933
|
+
code: 'ARRAY_OPERATOR_TOO_LARGE',
|
|
934
|
+
status: 400
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
boundMatchOperators(value);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
810
943
|
|
|
811
944
|
|
|
812
945
|
|
|
@@ -1007,14 +1140,18 @@ function countAggregatePipelineStages(pipeline: unknown): number {
|
|
|
1007
1140
|
}
|
|
1008
1141
|
|
|
1009
1142
|
|
|
1010
|
-
function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown
|
|
1143
|
+
function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>, allowedCollections?: string[]): {
|
|
1011
1144
|
isSafe: boolean;
|
|
1012
1145
|
forbiddenKeys: string[];
|
|
1013
1146
|
error: AppError | null;
|
|
1014
1147
|
} {
|
|
1015
1148
|
|
|
1016
|
-
// Unauthorized pipeline keys
|
|
1017
|
-
const UNAUTHORIZED_PIPELINE_KEYS = [
|
|
1149
|
+
// Unauthorized pipeline keys — Faille 12: include cross-collection exfiltration stages
|
|
1150
|
+
const UNAUTHORIZED_PIPELINE_KEYS = [
|
|
1151
|
+
'$out', '$merge', '$function', '$where', '$accumulator',
|
|
1152
|
+
'$unionWith', '$collStats', '$indexStats', '$planCacheStats',
|
|
1153
|
+
'$currentOp', '$listLocalSessions', '$listSessions', '$changeStream',
|
|
1154
|
+
];
|
|
1018
1155
|
const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
|
|
1019
1156
|
if (forbiddenFound.length > 0) {
|
|
1020
1157
|
return {
|
|
@@ -1024,6 +1161,28 @@ function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
|
|
|
1024
1161
|
}
|
|
1025
1162
|
}
|
|
1026
1163
|
|
|
1164
|
+
// Faille 12: restrict $lookup / $graphLookup targets to declared tenant collections
|
|
1165
|
+
if (allowedCollections && allowedCollections.length > 0) {
|
|
1166
|
+
const badTargets: string[] = [];
|
|
1167
|
+
walkAggregatePipeline(pipeline, (stage) => {
|
|
1168
|
+
const lookup = (stage as any).$lookup;
|
|
1169
|
+
if (lookup?.from && !allowedCollections.includes(lookup.from)) {
|
|
1170
|
+
badTargets.push(lookup.from);
|
|
1171
|
+
}
|
|
1172
|
+
const graph = (stage as any).$graphLookup;
|
|
1173
|
+
if (graph?.from && !allowedCollections.includes(graph.from)) {
|
|
1174
|
+
badTargets.push(graph.from);
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
if (badTargets.length > 0) {
|
|
1178
|
+
return {
|
|
1179
|
+
isSafe: false,
|
|
1180
|
+
forbiddenKeys: badTargets,
|
|
1181
|
+
error: new AppError('Unauthorized $lookup target collection: ' + [...new Set(badTargets)].join(', '), { code: 'UNAUTHORIZED_LOOKUP_TARGET', status: 400 })
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1027
1186
|
// $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
|
|
1028
1187
|
let renameForbiddenKeys: string[] = [];
|
|
1029
1188
|
let renameError: AppError | null = null;
|
|
@@ -1066,6 +1225,7 @@ export {
|
|
|
1066
1225
|
reorder,
|
|
1067
1226
|
omit,
|
|
1068
1227
|
pick,
|
|
1228
|
+
unflattenKeys,
|
|
1069
1229
|
toJson,
|
|
1070
1230
|
stringToBoolean,
|
|
1071
1231
|
cleanDeep,
|