@frontmcp/guard 0.0.1
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/LICENSE +201 -0
- package/README.md +395 -0
- package/concurrency/index.d.ts +2 -0
- package/concurrency/semaphore.d.ts +26 -0
- package/concurrency/types.d.ts +22 -0
- package/errors/errors.d.ts +54 -0
- package/errors/index.d.ts +1 -0
- package/esm/index.mjs +554 -0
- package/esm/package.json +61 -0
- package/index.d.ts +14 -0
- package/index.js +600 -0
- package/ip-filter/index.d.ts +2 -0
- package/ip-filter/ip-filter.d.ts +21 -0
- package/ip-filter/types.d.ts +29 -0
- package/manager/guard.factory.d.ts +15 -0
- package/manager/guard.manager.d.ts +47 -0
- package/manager/index.d.ts +3 -0
- package/manager/types.d.ts +45 -0
- package/package.json +61 -0
- package/partition-key/index.d.ts +2 -0
- package/partition-key/partition-key.resolver.d.ts +14 -0
- package/partition-key/types.d.ts +27 -0
- package/rate-limit/index.d.ts +2 -0
- package/rate-limit/rate-limiter.d.ts +25 -0
- package/rate-limit/types.d.ts +25 -0
- package/schemas/index.d.ts +1 -0
- package/schemas/schemas.d.ts +157 -0
- package/timeout/index.d.ts +2 -0
- package/timeout/timeout.d.ts +9 -0
- package/timeout/types.d.ts +10 -0
package/index.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// libs/guard/src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ConcurrencyLimitError: () => ConcurrencyLimitError,
|
|
24
|
+
DistributedSemaphore: () => DistributedSemaphore,
|
|
25
|
+
ExecutionTimeoutError: () => ExecutionTimeoutError,
|
|
26
|
+
GuardError: () => GuardError,
|
|
27
|
+
GuardManager: () => GuardManager,
|
|
28
|
+
IpBlockedError: () => IpBlockedError,
|
|
29
|
+
IpFilter: () => IpFilter,
|
|
30
|
+
IpNotAllowedError: () => IpNotAllowedError,
|
|
31
|
+
QueueTimeoutError: () => QueueTimeoutError,
|
|
32
|
+
SlidingWindowRateLimiter: () => SlidingWindowRateLimiter,
|
|
33
|
+
buildStorageKey: () => buildStorageKey,
|
|
34
|
+
concurrencyConfigSchema: () => concurrencyConfigSchema,
|
|
35
|
+
createGuardManager: () => createGuardManager,
|
|
36
|
+
guardConfigSchema: () => guardConfigSchema,
|
|
37
|
+
ipFilterConfigSchema: () => ipFilterConfigSchema,
|
|
38
|
+
partitionKeySchema: () => partitionKeySchema,
|
|
39
|
+
rateLimitConfigSchema: () => rateLimitConfigSchema,
|
|
40
|
+
resolvePartitionKey: () => resolvePartitionKey,
|
|
41
|
+
timeoutConfigSchema: () => timeoutConfigSchema,
|
|
42
|
+
withTimeout: () => withTimeout
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(index_exports);
|
|
45
|
+
|
|
46
|
+
// libs/guard/src/errors/errors.ts
|
|
47
|
+
var GuardError = class extends Error {
|
|
48
|
+
constructor(message, code, statusCode) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = this.constructor.name;
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.statusCode = statusCode;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var ExecutionTimeoutError = class extends GuardError {
|
|
56
|
+
constructor(entityName, timeoutMs) {
|
|
57
|
+
super(`Execution of "${entityName}" timed out after ${timeoutMs}ms`, "EXECUTION_TIMEOUT", 408);
|
|
58
|
+
this.entityName = entityName;
|
|
59
|
+
this.timeoutMs = timeoutMs;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var ConcurrencyLimitError = class extends GuardError {
|
|
63
|
+
constructor(entityName, maxConcurrent) {
|
|
64
|
+
super(`Concurrency limit reached for "${entityName}" (max: ${maxConcurrent})`, "CONCURRENCY_LIMIT", 429);
|
|
65
|
+
this.entityName = entityName;
|
|
66
|
+
this.maxConcurrent = maxConcurrent;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var QueueTimeoutError = class extends GuardError {
|
|
70
|
+
constructor(entityName, queueTimeoutMs) {
|
|
71
|
+
super(
|
|
72
|
+
`Queue timeout for "${entityName}" after waiting ${queueTimeoutMs}ms for a concurrency slot`,
|
|
73
|
+
"QUEUE_TIMEOUT",
|
|
74
|
+
429
|
|
75
|
+
);
|
|
76
|
+
this.entityName = entityName;
|
|
77
|
+
this.queueTimeoutMs = queueTimeoutMs;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var IpBlockedError = class extends GuardError {
|
|
81
|
+
constructor(clientIp) {
|
|
82
|
+
super(`IP address "${clientIp}" is blocked`, "IP_BLOCKED", 403);
|
|
83
|
+
this.clientIp = clientIp;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var IpNotAllowedError = class extends GuardError {
|
|
87
|
+
constructor(clientIp) {
|
|
88
|
+
super(`IP address "${clientIp}" is not allowed`, "IP_NOT_ALLOWED", 403);
|
|
89
|
+
this.clientIp = clientIp;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// libs/guard/src/schemas/schemas.ts
|
|
94
|
+
var import_zod = require("zod");
|
|
95
|
+
var partitionKeySchema = import_zod.z.union([
|
|
96
|
+
import_zod.z.enum(["ip", "session", "userId", "global"]),
|
|
97
|
+
import_zod.z.custom(
|
|
98
|
+
(val) => typeof val === "function"
|
|
99
|
+
)
|
|
100
|
+
]);
|
|
101
|
+
var rateLimitConfigSchema = import_zod.z.object({
|
|
102
|
+
maxRequests: import_zod.z.number().int().positive(),
|
|
103
|
+
windowMs: import_zod.z.number().int().positive().optional().default(6e4),
|
|
104
|
+
partitionBy: partitionKeySchema.optional().default("global")
|
|
105
|
+
});
|
|
106
|
+
var concurrencyConfigSchema = import_zod.z.object({
|
|
107
|
+
maxConcurrent: import_zod.z.number().int().positive(),
|
|
108
|
+
queueTimeoutMs: import_zod.z.number().int().nonnegative().optional().default(0),
|
|
109
|
+
partitionBy: partitionKeySchema.optional().default("global")
|
|
110
|
+
});
|
|
111
|
+
var timeoutConfigSchema = import_zod.z.object({
|
|
112
|
+
executeMs: import_zod.z.number().int().positive()
|
|
113
|
+
});
|
|
114
|
+
var ipFilterConfigSchema = import_zod.z.object({
|
|
115
|
+
allowList: import_zod.z.array(import_zod.z.string()).optional(),
|
|
116
|
+
denyList: import_zod.z.array(import_zod.z.string()).optional(),
|
|
117
|
+
defaultAction: import_zod.z.enum(["allow", "deny"]).optional().default("allow"),
|
|
118
|
+
trustProxy: import_zod.z.boolean().optional().default(false),
|
|
119
|
+
trustedProxyDepth: import_zod.z.number().int().positive().optional().default(1)
|
|
120
|
+
});
|
|
121
|
+
var guardConfigSchema = import_zod.z.object({
|
|
122
|
+
enabled: import_zod.z.boolean(),
|
|
123
|
+
storage: import_zod.z.looseObject({}).optional(),
|
|
124
|
+
keyPrefix: import_zod.z.string().optional().default("mcp:guard:"),
|
|
125
|
+
global: rateLimitConfigSchema.optional(),
|
|
126
|
+
globalConcurrency: concurrencyConfigSchema.optional(),
|
|
127
|
+
defaultRateLimit: rateLimitConfigSchema.optional(),
|
|
128
|
+
defaultConcurrency: concurrencyConfigSchema.optional(),
|
|
129
|
+
defaultTimeout: timeoutConfigSchema.optional(),
|
|
130
|
+
ipFilter: ipFilterConfigSchema.optional()
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// libs/guard/src/partition-key/partition-key.resolver.ts
|
|
134
|
+
function resolvePartitionKey(partitionBy, context) {
|
|
135
|
+
if (!partitionBy || partitionBy === "global") {
|
|
136
|
+
return "global";
|
|
137
|
+
}
|
|
138
|
+
const ctx = context ?? { sessionId: "anonymous" };
|
|
139
|
+
if (typeof partitionBy === "function") {
|
|
140
|
+
return partitionBy(ctx);
|
|
141
|
+
}
|
|
142
|
+
switch (partitionBy) {
|
|
143
|
+
case "ip":
|
|
144
|
+
return ctx.clientIp ?? "unknown-ip";
|
|
145
|
+
case "session":
|
|
146
|
+
return ctx.sessionId;
|
|
147
|
+
case "userId":
|
|
148
|
+
return ctx.userId ?? ctx.sessionId;
|
|
149
|
+
default:
|
|
150
|
+
return "global";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function buildStorageKey(entityName, partitionKey, suffix) {
|
|
154
|
+
const parts = [entityName, partitionKey];
|
|
155
|
+
if (suffix) {
|
|
156
|
+
parts.push(suffix);
|
|
157
|
+
}
|
|
158
|
+
return parts.join(":");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// libs/guard/src/rate-limit/rate-limiter.ts
|
|
162
|
+
var SlidingWindowRateLimiter = class {
|
|
163
|
+
constructor(storage) {
|
|
164
|
+
this.storage = storage;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check whether a request is allowed under the rate limit.
|
|
168
|
+
* If allowed, the counter is atomically incremented.
|
|
169
|
+
*/
|
|
170
|
+
async check(key, maxRequests, windowMs) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const currentWindowStart = Math.floor(now / windowMs) * windowMs;
|
|
173
|
+
const previousWindowStart = currentWindowStart - windowMs;
|
|
174
|
+
const currentKey = `${key}:${currentWindowStart}`;
|
|
175
|
+
const previousKey = `${key}:${previousWindowStart}`;
|
|
176
|
+
const [currentRaw, previousRaw] = await this.storage.mget([currentKey, previousKey]);
|
|
177
|
+
const currentCount = parseInt(currentRaw ?? "0", 10) || 0;
|
|
178
|
+
const previousCount = parseInt(previousRaw ?? "0", 10) || 0;
|
|
179
|
+
const elapsed = now - currentWindowStart;
|
|
180
|
+
const weight = 1 - elapsed / windowMs;
|
|
181
|
+
const estimatedCount = previousCount * weight + currentCount;
|
|
182
|
+
if (estimatedCount >= maxRequests) {
|
|
183
|
+
return {
|
|
184
|
+
allowed: false,
|
|
185
|
+
remaining: 0,
|
|
186
|
+
resetMs: windowMs - elapsed,
|
|
187
|
+
retryAfterMs: windowMs - elapsed
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
await this.storage.incr(currentKey);
|
|
191
|
+
const ttlSeconds = Math.ceil(windowMs * 2 / 1e3);
|
|
192
|
+
await this.storage.expire(currentKey, ttlSeconds);
|
|
193
|
+
const remaining = Math.max(0, Math.floor(maxRequests - estimatedCount - 1));
|
|
194
|
+
return {
|
|
195
|
+
allowed: true,
|
|
196
|
+
remaining,
|
|
197
|
+
resetMs: windowMs - elapsed
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Reset the rate limit counters for a key.
|
|
202
|
+
*/
|
|
203
|
+
async reset(key, windowMs) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const currentWindowStart = Math.floor(now / windowMs) * windowMs;
|
|
206
|
+
const previousWindowStart = currentWindowStart - windowMs;
|
|
207
|
+
await this.storage.mdelete([`${key}:${currentWindowStart}`, `${key}:${previousWindowStart}`]);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// libs/guard/src/concurrency/semaphore.ts
|
|
212
|
+
var import_utils = require("@frontmcp/utils");
|
|
213
|
+
var DEFAULT_TICKET_TTL_SECONDS = 300;
|
|
214
|
+
var MIN_POLL_INTERVAL_MS = 100;
|
|
215
|
+
var MAX_POLL_INTERVAL_MS = 1e3;
|
|
216
|
+
var DistributedSemaphore = class {
|
|
217
|
+
constructor(storage, ticketTtlSeconds = DEFAULT_TICKET_TTL_SECONDS) {
|
|
218
|
+
this.storage = storage;
|
|
219
|
+
this.ticketTtlSeconds = ticketTtlSeconds;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Attempt to acquire a concurrency slot.
|
|
223
|
+
*
|
|
224
|
+
* @returns A SemaphoreTicket if acquired, or null if rejected
|
|
225
|
+
* @throws QueueTimeoutError if queued and timeout expires
|
|
226
|
+
*/
|
|
227
|
+
async acquire(key, maxConcurrent, queueTimeoutMs, entityName) {
|
|
228
|
+
const ticket = await this.tryAcquire(key, maxConcurrent);
|
|
229
|
+
if (ticket) return ticket;
|
|
230
|
+
if (queueTimeoutMs <= 0) return null;
|
|
231
|
+
return this.waitForSlot(key, maxConcurrent, queueTimeoutMs, entityName);
|
|
232
|
+
}
|
|
233
|
+
async tryAcquire(key, maxConcurrent) {
|
|
234
|
+
const countKey = `${key}:count`;
|
|
235
|
+
const newCount = await this.storage.incr(countKey);
|
|
236
|
+
if (newCount <= maxConcurrent) {
|
|
237
|
+
const ticketId = (0, import_utils.randomUUID)();
|
|
238
|
+
const ticketKey = `${key}:ticket:${ticketId}`;
|
|
239
|
+
await this.storage.set(ticketKey, String(Date.now()), {
|
|
240
|
+
ttlSeconds: this.ticketTtlSeconds
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
ticket: ticketId,
|
|
244
|
+
release: () => this.release(key, ticketId)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
await this.storage.decr(countKey);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
async release(key, ticketId) {
|
|
251
|
+
const countKey = `${key}:count`;
|
|
252
|
+
const ticketKey = `${key}:ticket:${ticketId}`;
|
|
253
|
+
await this.storage.delete(ticketKey);
|
|
254
|
+
const newCount = await this.storage.decr(countKey);
|
|
255
|
+
if (newCount < 0) {
|
|
256
|
+
await this.storage.set(countKey, "0");
|
|
257
|
+
}
|
|
258
|
+
if (this.storage.supportsPubSub()) {
|
|
259
|
+
try {
|
|
260
|
+
await this.storage.publish(`${key}:released`, ticketId);
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async waitForSlot(key, maxConcurrent, queueTimeoutMs, entityName) {
|
|
266
|
+
const deadline = Date.now() + queueTimeoutMs;
|
|
267
|
+
let pollInterval = MIN_POLL_INTERVAL_MS;
|
|
268
|
+
let unsubscribe;
|
|
269
|
+
let notified = false;
|
|
270
|
+
if (this.storage.supportsPubSub()) {
|
|
271
|
+
try {
|
|
272
|
+
unsubscribe = await this.storage.subscribe(`${key}:released`, () => {
|
|
273
|
+
notified = true;
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
const ticket = await this.tryAcquire(key, maxConcurrent);
|
|
281
|
+
if (ticket) return ticket;
|
|
282
|
+
const remainingMs = deadline - Date.now();
|
|
283
|
+
if (remainingMs <= 0) break;
|
|
284
|
+
const waitMs = Math.min(pollInterval, remainingMs);
|
|
285
|
+
if (notified) {
|
|
286
|
+
notified = false;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
await sleep(waitMs);
|
|
290
|
+
pollInterval = Math.min(pollInterval * 2, MAX_POLL_INTERVAL_MS);
|
|
291
|
+
}
|
|
292
|
+
} finally {
|
|
293
|
+
if (unsubscribe) {
|
|
294
|
+
try {
|
|
295
|
+
await unsubscribe();
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw new QueueTimeoutError(entityName, queueTimeoutMs);
|
|
301
|
+
}
|
|
302
|
+
async getActiveCount(key) {
|
|
303
|
+
const countKey = `${key}:count`;
|
|
304
|
+
const raw = await this.storage.get(countKey);
|
|
305
|
+
return Math.max(0, parseInt(raw ?? "0", 10) || 0);
|
|
306
|
+
}
|
|
307
|
+
async forceReset(key) {
|
|
308
|
+
const countKey = `${key}:count`;
|
|
309
|
+
await this.storage.delete(countKey);
|
|
310
|
+
const ticketKeys = await this.storage.keys(`${key}:ticket:*`);
|
|
311
|
+
if (ticketKeys.length > 0) {
|
|
312
|
+
await this.storage.mdelete(ticketKeys);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
function sleep(ms) {
|
|
317
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// libs/guard/src/timeout/timeout.ts
|
|
321
|
+
async function withTimeout(fn, timeoutMs, entityName) {
|
|
322
|
+
const controller = new AbortController();
|
|
323
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
324
|
+
try {
|
|
325
|
+
return await Promise.race([
|
|
326
|
+
fn(),
|
|
327
|
+
new Promise((_, reject) => {
|
|
328
|
+
controller.signal.addEventListener("abort", () => {
|
|
329
|
+
reject(new ExecutionTimeoutError(entityName, timeoutMs));
|
|
330
|
+
});
|
|
331
|
+
})
|
|
332
|
+
]);
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// libs/guard/src/ip-filter/ip-filter.ts
|
|
339
|
+
var IpFilter = class {
|
|
340
|
+
constructor(config) {
|
|
341
|
+
this.allowRules = (config.allowList ?? []).map(parseCidr);
|
|
342
|
+
this.denyRules = (config.denyList ?? []).map(parseCidr);
|
|
343
|
+
this.defaultAction = config.defaultAction ?? "allow";
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Check if a client IP is allowed.
|
|
347
|
+
*/
|
|
348
|
+
check(clientIp) {
|
|
349
|
+
const parsed = parseIp(clientIp);
|
|
350
|
+
if (parsed === null) {
|
|
351
|
+
return { allowed: this.defaultAction === "allow", reason: "default" };
|
|
352
|
+
}
|
|
353
|
+
for (const rule of this.denyRules) {
|
|
354
|
+
if (matchesCidr(parsed, rule)) {
|
|
355
|
+
return { allowed: false, reason: "denylisted", matchedRule: rule.raw };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (this.allowRules.length > 0) {
|
|
359
|
+
for (const rule of this.allowRules) {
|
|
360
|
+
if (matchesCidr(parsed, rule)) {
|
|
361
|
+
return { allowed: true, reason: "allowlisted", matchedRule: rule.raw };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (this.defaultAction === "deny") {
|
|
365
|
+
return { allowed: false, reason: "default" };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { allowed: this.defaultAction === "allow", reason: "default" };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Check if an IP is on the allow list (bypasses rate limiting).
|
|
372
|
+
*/
|
|
373
|
+
isAllowListed(clientIp) {
|
|
374
|
+
const parsed = parseIp(clientIp);
|
|
375
|
+
if (parsed === null) return false;
|
|
376
|
+
return this.allowRules.some((rule) => matchesCidr(parsed, rule));
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
function parseIp(ip) {
|
|
380
|
+
const trimmed = ip.trim();
|
|
381
|
+
if (trimmed.includes(".") && !trimmed.includes(":")) {
|
|
382
|
+
const value = parseIpv4(trimmed);
|
|
383
|
+
if (value === null) return null;
|
|
384
|
+
return { value, isV6: false };
|
|
385
|
+
}
|
|
386
|
+
if (trimmed.includes(":")) {
|
|
387
|
+
const value = parseIpv6(trimmed);
|
|
388
|
+
if (value === null) return null;
|
|
389
|
+
return { value, isV6: true };
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
function parseIpv4(ip) {
|
|
394
|
+
const parts = ip.split(".");
|
|
395
|
+
if (parts.length !== 4) return null;
|
|
396
|
+
let result = 0n;
|
|
397
|
+
for (const part of parts) {
|
|
398
|
+
const num = parseInt(part, 10);
|
|
399
|
+
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
400
|
+
result = result << 8n | BigInt(num);
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
function parseIpv6(ip) {
|
|
405
|
+
const v4MappedMatch = ip.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
406
|
+
if (v4MappedMatch) {
|
|
407
|
+
const v4 = parseIpv4(v4MappedMatch[1]);
|
|
408
|
+
if (v4 === null) return null;
|
|
409
|
+
return 0xffff00000000n | v4;
|
|
410
|
+
}
|
|
411
|
+
let expanded = ip;
|
|
412
|
+
if (expanded.includes("::")) {
|
|
413
|
+
const halves = expanded.split("::");
|
|
414
|
+
if (halves.length > 2) return null;
|
|
415
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
416
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
417
|
+
const missing = 8 - left.length - right.length;
|
|
418
|
+
if (missing < 0) return null;
|
|
419
|
+
const middle = Array(missing).fill("0");
|
|
420
|
+
expanded = [...left, ...middle, ...right].join(":");
|
|
421
|
+
}
|
|
422
|
+
const groups = expanded.split(":");
|
|
423
|
+
if (groups.length !== 8) return null;
|
|
424
|
+
let result = 0n;
|
|
425
|
+
for (const group of groups) {
|
|
426
|
+
const num = parseInt(group, 16);
|
|
427
|
+
if (isNaN(num) || num < 0 || num > 65535) return null;
|
|
428
|
+
result = result << 16n | BigInt(num);
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
function parseCidr(cidr) {
|
|
433
|
+
const [ipPart, prefixPart] = cidr.split("/");
|
|
434
|
+
const parsed = parseIp(ipPart);
|
|
435
|
+
if (parsed === null) {
|
|
436
|
+
return { raw: cidr, ip: 0n, mask: 0n, isV6: false, valid: false };
|
|
437
|
+
}
|
|
438
|
+
const maxBits = parsed.isV6 ? 128 : 32;
|
|
439
|
+
const prefixLen = prefixPart !== void 0 ? parseInt(prefixPart, 10) : maxBits;
|
|
440
|
+
if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > maxBits) {
|
|
441
|
+
return { raw: cidr, ip: 0n, mask: 0n, isV6: parsed.isV6, valid: false };
|
|
442
|
+
}
|
|
443
|
+
const mask = prefixLen === 0 ? 0n : (1n << BigInt(maxBits)) - 1n << BigInt(maxBits - prefixLen);
|
|
444
|
+
return {
|
|
445
|
+
raw: cidr,
|
|
446
|
+
ip: parsed.value & mask,
|
|
447
|
+
mask,
|
|
448
|
+
isV6: parsed.isV6,
|
|
449
|
+
valid: true
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function matchesCidr(ip, rule) {
|
|
453
|
+
if (!rule.valid) return false;
|
|
454
|
+
if (ip.isV6 !== rule.isV6) return false;
|
|
455
|
+
return (ip.value & rule.mask) === rule.ip;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// libs/guard/src/manager/guard.manager.ts
|
|
459
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
460
|
+
var GuardManager = class {
|
|
461
|
+
constructor(storage, config) {
|
|
462
|
+
this.storage = storage;
|
|
463
|
+
this.config = config;
|
|
464
|
+
this.rateLimiter = new SlidingWindowRateLimiter(storage);
|
|
465
|
+
this.semaphore = new DistributedSemaphore(storage);
|
|
466
|
+
if (config.ipFilter) {
|
|
467
|
+
this.ipFilter = new IpFilter(config.ipFilter);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// ============================================
|
|
471
|
+
// IP Filtering
|
|
472
|
+
// ============================================
|
|
473
|
+
/**
|
|
474
|
+
* Check if a client IP is allowed by the IP filter.
|
|
475
|
+
* Returns undefined if no IP filter is configured.
|
|
476
|
+
*/
|
|
477
|
+
checkIpFilter(clientIp) {
|
|
478
|
+
if (!this.ipFilter || !clientIp) return void 0;
|
|
479
|
+
return this.ipFilter.check(clientIp);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Check if a client IP is on the allow list (bypasses rate limiting).
|
|
483
|
+
*/
|
|
484
|
+
isIpAllowListed(clientIp) {
|
|
485
|
+
if (!this.ipFilter || !clientIp) return false;
|
|
486
|
+
return this.ipFilter.isAllowListed(clientIp);
|
|
487
|
+
}
|
|
488
|
+
// ============================================
|
|
489
|
+
// Rate Limiting
|
|
490
|
+
// ============================================
|
|
491
|
+
/**
|
|
492
|
+
* Check per-entity rate limit.
|
|
493
|
+
* Merges entity config with app-level defaults (entity takes precedence).
|
|
494
|
+
*/
|
|
495
|
+
async checkRateLimit(entityName, entityConfig, context) {
|
|
496
|
+
const config = entityConfig ?? this.config.defaultRateLimit;
|
|
497
|
+
if (!config) {
|
|
498
|
+
return { allowed: true, remaining: Infinity, resetMs: 0 };
|
|
499
|
+
}
|
|
500
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
501
|
+
const storageKey = buildStorageKey(entityName, partitionKey, "rl");
|
|
502
|
+
const windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
503
|
+
return this.rateLimiter.check(storageKey, config.maxRequests, windowMs);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Check global rate limit.
|
|
507
|
+
*/
|
|
508
|
+
async checkGlobalRateLimit(context) {
|
|
509
|
+
const config = this.config.global;
|
|
510
|
+
if (!config) {
|
|
511
|
+
return { allowed: true, remaining: Infinity, resetMs: 0 };
|
|
512
|
+
}
|
|
513
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
514
|
+
const storageKey = buildStorageKey("__global__", partitionKey, "rl");
|
|
515
|
+
const windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
516
|
+
return this.rateLimiter.check(storageKey, config.maxRequests, windowMs);
|
|
517
|
+
}
|
|
518
|
+
// ============================================
|
|
519
|
+
// Concurrency Control
|
|
520
|
+
// ============================================
|
|
521
|
+
/**
|
|
522
|
+
* Acquire a concurrency slot for an entity.
|
|
523
|
+
*/
|
|
524
|
+
async acquireSemaphore(entityName, entityConfig, context) {
|
|
525
|
+
const config = entityConfig ?? this.config.defaultConcurrency;
|
|
526
|
+
if (!config) return null;
|
|
527
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
528
|
+
const storageKey = buildStorageKey(entityName, partitionKey, "sem");
|
|
529
|
+
const queueTimeoutMs = config.queueTimeoutMs ?? 0;
|
|
530
|
+
return this.semaphore.acquire(storageKey, config.maxConcurrent, queueTimeoutMs, entityName);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Acquire a global concurrency slot.
|
|
534
|
+
*/
|
|
535
|
+
async acquireGlobalSemaphore(context) {
|
|
536
|
+
const config = this.config.globalConcurrency;
|
|
537
|
+
if (!config) return null;
|
|
538
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
539
|
+
const storageKey = buildStorageKey("__global__", partitionKey, "sem");
|
|
540
|
+
const queueTimeoutMs = config.queueTimeoutMs ?? 0;
|
|
541
|
+
return this.semaphore.acquire(storageKey, config.maxConcurrent, queueTimeoutMs, "__global__");
|
|
542
|
+
}
|
|
543
|
+
// ============================================
|
|
544
|
+
// Lifecycle
|
|
545
|
+
// ============================================
|
|
546
|
+
async destroy() {
|
|
547
|
+
await this.storage.disconnect();
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// libs/guard/src/manager/guard.factory.ts
|
|
552
|
+
var import_utils2 = require("@frontmcp/utils");
|
|
553
|
+
async function createGuardManager(args) {
|
|
554
|
+
const { config, logger } = args;
|
|
555
|
+
const keyPrefix = config.keyPrefix ?? "mcp:guard:";
|
|
556
|
+
let storage;
|
|
557
|
+
if (config.storage) {
|
|
558
|
+
storage = await (0, import_utils2.createStorage)(config.storage);
|
|
559
|
+
} else {
|
|
560
|
+
logger?.warn(
|
|
561
|
+
"GuardManager: No storage config provided, using in-memory storage (not suitable for distributed deployments)"
|
|
562
|
+
);
|
|
563
|
+
storage = (0, import_utils2.createMemoryStorage)();
|
|
564
|
+
}
|
|
565
|
+
await storage.connect();
|
|
566
|
+
const namespacedStorage = storage.namespace(keyPrefix);
|
|
567
|
+
logger?.info("GuardManager initialized", {
|
|
568
|
+
keyPrefix,
|
|
569
|
+
hasGlobalRateLimit: !!config.global,
|
|
570
|
+
hasGlobalConcurrency: !!config.globalConcurrency,
|
|
571
|
+
hasDefaultRateLimit: !!config.defaultRateLimit,
|
|
572
|
+
hasDefaultConcurrency: !!config.defaultConcurrency,
|
|
573
|
+
hasDefaultTimeout: !!config.defaultTimeout,
|
|
574
|
+
hasIpFilter: !!config.ipFilter
|
|
575
|
+
});
|
|
576
|
+
return new GuardManager(namespacedStorage, config);
|
|
577
|
+
}
|
|
578
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
579
|
+
0 && (module.exports = {
|
|
580
|
+
ConcurrencyLimitError,
|
|
581
|
+
DistributedSemaphore,
|
|
582
|
+
ExecutionTimeoutError,
|
|
583
|
+
GuardError,
|
|
584
|
+
GuardManager,
|
|
585
|
+
IpBlockedError,
|
|
586
|
+
IpFilter,
|
|
587
|
+
IpNotAllowedError,
|
|
588
|
+
QueueTimeoutError,
|
|
589
|
+
SlidingWindowRateLimiter,
|
|
590
|
+
buildStorageKey,
|
|
591
|
+
concurrencyConfigSchema,
|
|
592
|
+
createGuardManager,
|
|
593
|
+
guardConfigSchema,
|
|
594
|
+
ipFilterConfigSchema,
|
|
595
|
+
partitionKeySchema,
|
|
596
|
+
rateLimitConfigSchema,
|
|
597
|
+
resolvePartitionKey,
|
|
598
|
+
timeoutConfigSchema,
|
|
599
|
+
withTimeout
|
|
600
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Filter
|
|
3
|
+
*
|
|
4
|
+
* Allow/deny list with CIDR support for IPv4 and IPv6.
|
|
5
|
+
* Pure computation — no storage or external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
import type { IpFilterConfig, IpFilterResult } from './types';
|
|
8
|
+
export declare class IpFilter {
|
|
9
|
+
private readonly allowRules;
|
|
10
|
+
private readonly denyRules;
|
|
11
|
+
private readonly defaultAction;
|
|
12
|
+
constructor(config: IpFilterConfig);
|
|
13
|
+
/**
|
|
14
|
+
* Check if a client IP is allowed.
|
|
15
|
+
*/
|
|
16
|
+
check(clientIp: string): IpFilterResult;
|
|
17
|
+
/**
|
|
18
|
+
* Check if an IP is on the allow list (bypasses rate limiting).
|
|
19
|
+
*/
|
|
20
|
+
isAllowListed(clientIp: string): boolean;
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Filter Types
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* IP filtering configuration.
|
|
6
|
+
*/
|
|
7
|
+
export interface IpFilterConfig {
|
|
8
|
+
/** IP addresses or CIDR ranges to always allow (bypass rate limiting). */
|
|
9
|
+
allowList?: string[];
|
|
10
|
+
/** IP addresses or CIDR ranges to always block. */
|
|
11
|
+
denyList?: string[];
|
|
12
|
+
/** Default action when IP matches neither list. @default 'allow' */
|
|
13
|
+
defaultAction?: 'allow' | 'deny';
|
|
14
|
+
/** Trust X-Forwarded-For header. @default false */
|
|
15
|
+
trustProxy?: boolean;
|
|
16
|
+
/** Max number of proxies to trust from X-Forwarded-For. @default 1 */
|
|
17
|
+
trustedProxyDepth?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Result of an IP filter check.
|
|
21
|
+
*/
|
|
22
|
+
export interface IpFilterResult {
|
|
23
|
+
/** Whether the request is allowed to proceed. */
|
|
24
|
+
allowed: boolean;
|
|
25
|
+
/** Reason for the decision. */
|
|
26
|
+
reason?: 'allowlisted' | 'denylisted' | 'default';
|
|
27
|
+
/** The specific rule that matched (IP or CIDR). */
|
|
28
|
+
matchedRule?: string;
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard Factory
|
|
3
|
+
*
|
|
4
|
+
* SDK-agnostic factory for creating GuardManager instances.
|
|
5
|
+
* Accepts a StorageConfig from @frontmcp/utils.
|
|
6
|
+
*/
|
|
7
|
+
import type { CreateGuardManagerArgs } from './types';
|
|
8
|
+
import { GuardManager } from './guard.manager';
|
|
9
|
+
/**
|
|
10
|
+
* Create and initialize a GuardManager with the appropriate storage backend.
|
|
11
|
+
*
|
|
12
|
+
* If config.storage is set, uses that directly.
|
|
13
|
+
* Otherwise falls back to in-memory storage.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createGuardManager(args: CreateGuardManagerArgs): Promise<GuardManager>;
|