@holo-js/security 0.3.27 → 0.4.0
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 +21 -0
- package/dist/.holo-build-fingerprint +1 -0
- package/dist/{chunk-WRMZRO77.mjs → chunk-D3ZN5MLJ.mjs} +112 -6
- package/dist/{chunk-PC2YCWDH.mjs → chunk-EINZXLC2.mjs} +7 -5
- package/dist/chunk-Q3PK4AGL.mjs +84 -0
- package/dist/{chunk-5SCCUNBO.mjs → chunk-RYQLS2OO.mjs} +429 -219
- package/dist/{chunk-FQ4X7X6L.mjs → chunk-U25HSHLJ.mjs} +4 -16
- package/dist/client.d.ts +1 -1
- package/dist/client.mjs +2 -2
- package/dist/config-BK6q6Yh9.d.ts +145 -0
- package/dist/config.d.ts +2 -136
- package/dist/config.mjs +1 -1
- package/dist/contracts.d.ts +15 -4
- package/dist/contracts.mjs +2 -4
- package/dist/drivers/redis-adapter.d.ts +3 -8
- package/dist/drivers/redis-adapter.mjs +13 -29
- package/dist/index.d.ts +24 -76
- package/dist/index.mjs +5 -6
- package/dist/next/server.d.ts +2 -1
- package/dist/next/server.mjs +34 -23
- package/dist/nuxt/server.mjs +53 -18
- package/dist/sveltekit/server.d.ts +3 -2
- package/dist/sveltekit/server.mjs +15 -20
- package/package.json +19 -13
- package/dist/chunk-F32JK536.mjs +0 -45
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SECURITY_XSRF_HEADER
|
|
3
|
+
} from "./chunk-EINZXLC2.mjs";
|
|
1
4
|
import {
|
|
2
5
|
SecurityCsrfError,
|
|
3
6
|
SecurityRateLimitError,
|
|
4
|
-
ip,
|
|
5
7
|
limit
|
|
6
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-U25HSHLJ.mjs";
|
|
7
9
|
import {
|
|
10
|
+
createForwardedRequestResolver,
|
|
8
11
|
normalizeCorsConfig,
|
|
9
|
-
normalizeSecurityConfig
|
|
10
|
-
|
|
12
|
+
normalizeSecurityConfig,
|
|
13
|
+
normalizeTrustedProxies,
|
|
14
|
+
resolveForwarding
|
|
15
|
+
} from "./chunk-D3ZN5MLJ.mjs";
|
|
11
16
|
|
|
12
|
-
// src/
|
|
13
|
-
import {
|
|
17
|
+
// src/proxy.ts
|
|
18
|
+
import { requestUrlInternals as requestUrlInternals2 } from "@holo-js/kernel";
|
|
14
19
|
|
|
15
20
|
// src/runtime.ts
|
|
21
|
+
import { requestBodyInternals, requestUrlInternals } from "@holo-js/kernel";
|
|
16
22
|
function getSecurityRuntimeState() {
|
|
17
23
|
const runtime = globalThis;
|
|
18
24
|
runtime.__holoSecurityRuntime__ ??= {};
|
|
@@ -24,11 +30,16 @@ var SecurityRuntimeNotConfiguredError = class extends Error {
|
|
|
24
30
|
}
|
|
25
31
|
};
|
|
26
32
|
function configureSecurityRuntime(bindings) {
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const config = bindings ? normalizeSecurityConfig(bindings.config) : void 0;
|
|
34
|
+
requestUrlInternals.configureForwardedRequestResolver(config ? createForwardedRequestResolver(config.trustedProxies) : void 0);
|
|
35
|
+
requestBodyInternals.configureMaxRequestBodyBytes(config?.maxBodyBytes);
|
|
36
|
+
requestBodyInternals.configureMaxUploadBodyBytes(config?.maxUploadBodyBytes);
|
|
37
|
+
getSecurityRuntimeState().bindings = bindings && config ? Object.freeze({
|
|
38
|
+
config,
|
|
29
39
|
cors: normalizeCorsConfig(bindings.cors),
|
|
30
40
|
rateLimitStore: bindings.rateLimitStore,
|
|
31
41
|
csrfSigningKey: bindings.csrfSigningKey,
|
|
42
|
+
csrfSession: bindings.csrfSession,
|
|
32
43
|
defaultKeyResolver: bindings.defaultKeyResolver
|
|
33
44
|
}) : void 0;
|
|
34
45
|
}
|
|
@@ -43,6 +54,9 @@ function getSecurityRuntimeBindings() {
|
|
|
43
54
|
return getSecurityRuntimeState().bindings;
|
|
44
55
|
}
|
|
45
56
|
function resetSecurityRuntime() {
|
|
57
|
+
requestUrlInternals.configureForwardedRequestResolver(void 0);
|
|
58
|
+
requestBodyInternals.configureMaxRequestBodyBytes(void 0);
|
|
59
|
+
requestBodyInternals.configureMaxUploadBodyBytes(void 0);
|
|
46
60
|
getSecurityRuntimeState().bindings = void 0;
|
|
47
61
|
}
|
|
48
62
|
var securityRuntimeInternals = {
|
|
@@ -50,11 +64,27 @@ var securityRuntimeInternals = {
|
|
|
50
64
|
};
|
|
51
65
|
|
|
52
66
|
// src/proxy.ts
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
var SecurityClientAddressUnavailableError = class extends Error {
|
|
68
|
+
constructor() {
|
|
69
|
+
super("[@holo-js/security] The client address is unavailable because this framework does not expose the connection address, so an address or CIDR list in trustedProxies can never match. Only '*' works here, and only behind a proxy that overwrites X-Forwarded-For, such as Vercel's edge, or a reverse proxy with the Next.js server bound to localhost or a private network. Set trustedProxies in config/security.ts to '*' (scaffolded apps read it from TRUSTED_PROXIES). If the app doesn't need IP identity, pass an explicit rate-limit key instead. See https://docs.holo-js.com/basics/security/cors-and-proxies#trusted-proxies");
|
|
70
|
+
this.name = "SecurityClientAddressUnavailableError";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
function ip(request) {
|
|
74
|
+
const trustedProxies = getSecurityRuntimeBindings()?.config.trustedProxies ?? normalizeTrustedProxies(void 0);
|
|
75
|
+
const { address } = resolveForwarding(
|
|
76
|
+
(name) => request.headers.get(name) ?? void 0,
|
|
77
|
+
requestUrlInternals2.currentConnectionAddress(),
|
|
78
|
+
trustedProxies
|
|
79
|
+
);
|
|
80
|
+
if (!address) throw new SecurityClientAddressUnavailableError();
|
|
81
|
+
return address;
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
// src/csrf.ts
|
|
85
|
+
import { cookieEncryptionInternals, HoloRequestBodyTooLargeError, readBoundedRequestBody, resolveRequestUrl } from "@holo-js/kernel";
|
|
86
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
87
|
+
|
|
58
88
|
// src/rate-limit.ts
|
|
59
89
|
function encodeBucketPart(value) {
|
|
60
90
|
return encodeURIComponent(value);
|
|
@@ -93,7 +123,7 @@ async function defaultRateLimitKey(request) {
|
|
|
93
123
|
if (typeof runtimeDefaultKey !== "undefined" && runtimeDefaultKey !== null) {
|
|
94
124
|
return normalizeResolvedLimiterKey(runtimeDefaultKey, "Default rate limiter key resolver");
|
|
95
125
|
}
|
|
96
|
-
return `ip:${ip(request
|
|
126
|
+
return `ip:${ip(request)}`;
|
|
97
127
|
}
|
|
98
128
|
async function resolveLimiterKey(name, limiter, options) {
|
|
99
129
|
if (typeof options.key === "string" && options.key.length > 0) {
|
|
@@ -171,32 +201,8 @@ var rateLimitInternals = {
|
|
|
171
201
|
};
|
|
172
202
|
|
|
173
203
|
// src/csrf.ts
|
|
174
|
-
var
|
|
175
|
-
|
|
176
|
-
if (!header) {
|
|
177
|
-
return Object.freeze({});
|
|
178
|
-
}
|
|
179
|
-
const decodeCookiePart = (value) => {
|
|
180
|
-
try {
|
|
181
|
-
return decodeURIComponent(value);
|
|
182
|
-
} catch {
|
|
183
|
-
return void 0;
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
const entries = header.split(";").map((segment) => segment.trim()).filter(Boolean).map((segment) => {
|
|
187
|
-
const separator = segment.indexOf("=");
|
|
188
|
-
if (separator <= 0) {
|
|
189
|
-
return void 0;
|
|
190
|
-
}
|
|
191
|
-
const key = decodeCookiePart(segment.slice(0, separator));
|
|
192
|
-
const value = decodeCookiePart(segment.slice(separator + 1));
|
|
193
|
-
if (!key || typeof value === "undefined") {
|
|
194
|
-
return void 0;
|
|
195
|
-
}
|
|
196
|
-
return [key, value];
|
|
197
|
-
}).filter((entry) => !!entry);
|
|
198
|
-
return Object.freeze(Object.fromEntries(entries));
|
|
199
|
-
}
|
|
204
|
+
var requestTokenCache = /* @__PURE__ */ new WeakMap();
|
|
205
|
+
var persistedTokens = /* @__PURE__ */ new WeakMap();
|
|
200
206
|
function serializeCookie(name, value, options = {}) {
|
|
201
207
|
const attributes = [
|
|
202
208
|
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
@@ -224,85 +230,90 @@ function isExcludedPath(request) {
|
|
|
224
230
|
const pathname = new URL(request.url).pathname;
|
|
225
231
|
return pathname === "/broadcasting/auth" || except.some((pattern) => matchesPathPattern(pathname, pattern));
|
|
226
232
|
}
|
|
227
|
-
function normalizeForwardedValue(value) {
|
|
228
|
-
return value.trim().replace(/^"|"$/g, "").toLowerCase();
|
|
229
|
-
}
|
|
230
|
-
function getForwardedProto(request) {
|
|
231
|
-
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim();
|
|
232
|
-
if (forwardedProto) {
|
|
233
|
-
return normalizeForwardedValue(forwardedProto);
|
|
234
|
-
}
|
|
235
|
-
const forwarded = request.headers.get("forwarded")?.split(",", 1)[0];
|
|
236
|
-
if (!forwarded) {
|
|
237
|
-
return void 0;
|
|
238
|
-
}
|
|
239
|
-
for (const segment of forwarded.split(";")) {
|
|
240
|
-
const [name, value] = segment.split("=", 2);
|
|
241
|
-
if (name?.trim().toLowerCase() === "proto" && value) {
|
|
242
|
-
return normalizeForwardedValue(value);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return void 0;
|
|
246
|
-
}
|
|
247
233
|
function isSecureRequest(request) {
|
|
248
|
-
return
|
|
234
|
+
return resolveRequestUrl(request).protocol === "https:";
|
|
249
235
|
}
|
|
250
236
|
function createCsrfToken() {
|
|
251
237
|
return randomBytes(32).toString("base64url");
|
|
252
238
|
}
|
|
253
|
-
function
|
|
254
|
-
|
|
255
|
-
if (configured) {
|
|
256
|
-
return configured;
|
|
257
|
-
}
|
|
258
|
-
throw new Error("[@holo-js/security] CSRF signing key is not configured.");
|
|
239
|
+
function csrfCookieName() {
|
|
240
|
+
return getSecurityRuntime().config.csrf.cookie;
|
|
259
241
|
}
|
|
260
|
-
function
|
|
261
|
-
return
|
|
242
|
+
function encryptCsrfToken(token2) {
|
|
243
|
+
return cookieEncryptionInternals.encryptCookieValue(csrfCookieName(), token2, getSecurityRuntime().csrfSigningKey);
|
|
262
244
|
}
|
|
263
|
-
function
|
|
264
|
-
return
|
|
245
|
+
function decryptCsrfToken(value) {
|
|
246
|
+
return cookieEncryptionInternals.decryptCookieValue(csrfCookieName(), value, getSecurityRuntime().csrfSigningKey);
|
|
265
247
|
}
|
|
266
|
-
function
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return null;
|
|
248
|
+
function tokensMatch(expected, submitted) {
|
|
249
|
+
if (!submitted) {
|
|
250
|
+
return false;
|
|
270
251
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
});
|
|
252
|
+
const expectedBytes = Buffer.from(expected);
|
|
253
|
+
const submittedBytes = Buffer.from(submitted);
|
|
254
|
+
return expectedBytes.length === submittedBytes.length && timingSafeEqual(expectedBytes, submittedBytes);
|
|
275
255
|
}
|
|
276
|
-
function
|
|
277
|
-
const
|
|
278
|
-
if (!
|
|
279
|
-
|
|
256
|
+
function csrfSession() {
|
|
257
|
+
const session = getSecurityRuntime().csrfSession;
|
|
258
|
+
if (!session) {
|
|
259
|
+
throw new Error("[@holo-js/security] CSRF protection requires a session. Install and configure @holo-js/session.");
|
|
280
260
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
261
|
+
return session;
|
|
262
|
+
}
|
|
263
|
+
async function readSessionToken(request) {
|
|
264
|
+
const stored = await csrfSession().read(request);
|
|
265
|
+
if (stored) {
|
|
266
|
+
persistedTokens.set(request, stored);
|
|
285
267
|
}
|
|
286
|
-
return
|
|
268
|
+
return stored;
|
|
287
269
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
return parseCookieHeader(request.headers.get("cookie"))[cookie2];
|
|
270
|
+
async function getSessionToken(request) {
|
|
271
|
+
return persistedTokens.get(request) ?? requestTokenCache.get(request) ?? await readSessionToken(request);
|
|
291
272
|
}
|
|
292
273
|
function getHeaderToken(request) {
|
|
293
274
|
const { header } = getSecurityRuntime().config.csrf;
|
|
275
|
+
if (header.toLowerCase() === SECURITY_XSRF_HEADER.toLowerCase()) {
|
|
276
|
+
return void 0;
|
|
277
|
+
}
|
|
294
278
|
return request.headers.get(header)?.trim() || void 0;
|
|
295
279
|
}
|
|
296
|
-
|
|
297
|
-
const
|
|
280
|
+
function getXsrfHeaderToken(request) {
|
|
281
|
+
const value = request.headers.get(SECURITY_XSRF_HEADER)?.trim();
|
|
282
|
+
return value ? decryptCsrfToken(value) : void 0;
|
|
283
|
+
}
|
|
284
|
+
function nonEmptyString(value) {
|
|
285
|
+
return typeof value === "string" && value ? value : void 0;
|
|
286
|
+
}
|
|
287
|
+
async function readBodyBytes(request) {
|
|
298
288
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
289
|
+
return await readBoundedRequestBody(request.clone());
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof HoloRequestBodyTooLargeError) throw error;
|
|
292
|
+
return void 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async function readBodyToken(request, field2) {
|
|
296
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
297
|
+
const bytes = await readBodyBytes(request);
|
|
298
|
+
if (!bytes) return void 0;
|
|
299
|
+
try {
|
|
300
|
+
if (/[/+]json\b/i.test(contentType)) {
|
|
301
|
+
const body = JSON.parse(new TextDecoder().decode(bytes));
|
|
302
|
+
return body && typeof body === "object" && !Array.isArray(body) && Object.hasOwn(body, field2) ? nonEmptyString(body[field2]) : void 0;
|
|
303
|
+
}
|
|
304
|
+
return nonEmptyString((await new Response(bytes, { headers: { "content-type": contentType } }).formData()).get(field2));
|
|
302
305
|
} catch {
|
|
303
306
|
return void 0;
|
|
304
307
|
}
|
|
305
308
|
}
|
|
309
|
+
async function readInputToken(request) {
|
|
310
|
+
const { field: field2 } = getSecurityRuntime().config.csrf;
|
|
311
|
+
return await readBodyToken(request, field2) ?? nonEmptyString(new URL(request.url).searchParams.get(field2));
|
|
312
|
+
}
|
|
313
|
+
async function getSubmittedToken(request) {
|
|
314
|
+
const token2 = await readInputToken(request) ?? getHeaderToken(request);
|
|
315
|
+
return token2 ? decryptCsrfToken(token2) ?? token2 : getXsrfHeaderToken(request);
|
|
316
|
+
}
|
|
306
317
|
async function verifyRequest(request, options) {
|
|
307
318
|
if (isSafeMethod(request.method)) {
|
|
308
319
|
return;
|
|
@@ -310,21 +321,21 @@ async function verifyRequest(request, options) {
|
|
|
310
321
|
if (options.allowExcludedPath && isExcludedPath(request)) {
|
|
311
322
|
return;
|
|
312
323
|
}
|
|
313
|
-
const
|
|
314
|
-
if (!
|
|
324
|
+
const sessionToken = await getSessionToken(request);
|
|
325
|
+
if (!sessionToken || !tokensMatch(sessionToken, await getSubmittedToken(request))) {
|
|
315
326
|
throw new SecurityCsrfError();
|
|
316
327
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
const requestToken = await readFormToken(request);
|
|
325
|
-
if (requestToken !== cookieToken) {
|
|
326
|
-
throw new SecurityCsrfError();
|
|
328
|
+
}
|
|
329
|
+
async function ensureSessionToken(request) {
|
|
330
|
+
const stored = persistedTokens.get(request) ?? await readSessionToken(request);
|
|
331
|
+
if (stored) {
|
|
332
|
+
return { token: stored, sessionCookies: [] };
|
|
327
333
|
}
|
|
334
|
+
const token2 = requestTokenCache.get(request) ?? createCsrfToken();
|
|
335
|
+
const written = await csrfSession().write(request, token2);
|
|
336
|
+
requestTokenCache.set(request, written.token);
|
|
337
|
+
persistedTokens.set(request, written.token);
|
|
338
|
+
return { token: written.token, sessionCookies: written.cookies };
|
|
328
339
|
}
|
|
329
340
|
function resolveShouldProtect(request, options = {}) {
|
|
330
341
|
if (options.csrf === false) {
|
|
@@ -346,16 +357,12 @@ function resolveShouldProtect(request, options = {}) {
|
|
|
346
357
|
return true;
|
|
347
358
|
}
|
|
348
359
|
async function token(request) {
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
return
|
|
352
|
-
}
|
|
353
|
-
const cached = generatedTokenCache.get(request);
|
|
354
|
-
if (cached) {
|
|
355
|
-
return cached;
|
|
360
|
+
const sessionToken = await getSessionToken(request);
|
|
361
|
+
if (sessionToken) {
|
|
362
|
+
return sessionToken;
|
|
356
363
|
}
|
|
357
|
-
const created =
|
|
358
|
-
|
|
364
|
+
const created = createCsrfToken();
|
|
365
|
+
requestTokenCache.set(request, created);
|
|
359
366
|
return created;
|
|
360
367
|
}
|
|
361
368
|
async function field(request) {
|
|
@@ -373,10 +380,11 @@ async function input(request) {
|
|
|
373
380
|
value: csrfField.value
|
|
374
381
|
});
|
|
375
382
|
}
|
|
383
|
+
async function encryptedToken(request, explicitToken) {
|
|
384
|
+
return encryptCsrfToken(explicitToken || await token(request));
|
|
385
|
+
}
|
|
376
386
|
async function cookie(request, explicitToken) {
|
|
377
|
-
|
|
378
|
-
const value = explicitToken ? isValidSignedCsrfToken(explicitToken) ? explicitToken : encodeCsrfToken(explicitToken) : await token(request);
|
|
379
|
-
return serializeCookie(config.cookie, value, {
|
|
387
|
+
return serializeCookie(csrfCookieName(), await encryptedToken(request, explicitToken), {
|
|
380
388
|
secure: isSecureRequest(request)
|
|
381
389
|
});
|
|
382
390
|
}
|
|
@@ -400,23 +408,24 @@ var csrf = Object.freeze({
|
|
|
400
408
|
cookie,
|
|
401
409
|
verify
|
|
402
410
|
});
|
|
411
|
+
function rememberSessionToken(request, token2) {
|
|
412
|
+
persistedTokens.set(request, token2);
|
|
413
|
+
}
|
|
403
414
|
var csrfInternals = {
|
|
404
415
|
createCsrfToken,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
416
|
+
rememberSessionToken,
|
|
417
|
+
ensureSessionToken,
|
|
418
|
+
requestTokenCache,
|
|
408
419
|
getHeaderToken,
|
|
420
|
+
getXsrfHeaderToken,
|
|
409
421
|
isSecureRequest,
|
|
410
|
-
|
|
422
|
+
readInputToken,
|
|
411
423
|
isExcludedPath,
|
|
412
424
|
isSafeMethod,
|
|
413
425
|
matchesPathPattern,
|
|
414
|
-
parseCookieHeader,
|
|
415
426
|
serializeCookie,
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
getCsrfSigningKey,
|
|
419
|
-
isValidSignedCsrfToken
|
|
427
|
+
decryptCsrfToken,
|
|
428
|
+
encryptCsrfToken
|
|
420
429
|
};
|
|
421
430
|
|
|
422
431
|
// src/cors.ts
|
|
@@ -472,7 +481,7 @@ function resolveAllowedOrigin(config, origin) {
|
|
|
472
481
|
return origin;
|
|
473
482
|
}
|
|
474
483
|
if (config.origins.includes("*")) {
|
|
475
|
-
return
|
|
484
|
+
return "*";
|
|
476
485
|
}
|
|
477
486
|
return void 0;
|
|
478
487
|
}
|
|
@@ -559,30 +568,37 @@ import { resolve } from "path";
|
|
|
559
568
|
|
|
560
569
|
// src/drivers/file.ts
|
|
561
570
|
import { createHash, randomUUID } from "crypto";
|
|
562
|
-
import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
|
|
571
|
+
import { mkdir, opendir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
|
|
563
572
|
import { dirname, join } from "path";
|
|
573
|
+
import { reportError } from "@holo-js/kernel/error-reporting";
|
|
564
574
|
var LOCK_OWNER_FILE = "owner";
|
|
565
575
|
var BUCKETS_DIRECTORY = "buckets";
|
|
576
|
+
var PRUNE_INTERVAL_MS = 6e4;
|
|
577
|
+
var SWEEP_BATCH_SIZE = 1e3;
|
|
566
578
|
function createBucketHash(key) {
|
|
567
579
|
return createHash("sha256").update(key).digest("hex");
|
|
568
580
|
}
|
|
569
|
-
function
|
|
570
|
-
const
|
|
571
|
-
return separatorIndex >= 0 ? key.slice(0, separatorIndex + 1) : key;
|
|
572
|
-
}
|
|
573
|
-
function createBucketNamespaceHash(key) {
|
|
574
|
-
return createBucketHash(createBucketNamespace(key));
|
|
575
|
-
}
|
|
576
|
-
function createBucketPrefixHashes(key) {
|
|
581
|
+
function hashBucketKey(key, keyHash) {
|
|
582
|
+
const hash = createHash("sha256");
|
|
577
583
|
const prefixHashes = [];
|
|
578
|
-
|
|
579
|
-
|
|
584
|
+
let namespaceHash;
|
|
585
|
+
let prefixLength = 0;
|
|
586
|
+
for (const character of key) {
|
|
587
|
+
hash.update(character);
|
|
588
|
+
prefixLength += character.length;
|
|
589
|
+
const prefixHash = prefixLength === key.length ? keyHash : hash.copy().digest("hex");
|
|
590
|
+
prefixHashes.push(prefixHash);
|
|
591
|
+
if (namespaceHash === void 0 && character === "|") {
|
|
592
|
+
namespaceHash = prefixHash;
|
|
593
|
+
}
|
|
580
594
|
}
|
|
581
|
-
return
|
|
595
|
+
return Object.freeze({
|
|
596
|
+
namespaceHash: namespaceHash ?? keyHash,
|
|
597
|
+
prefixHashes: Object.freeze(prefixHashes)
|
|
598
|
+
});
|
|
582
599
|
}
|
|
583
|
-
function
|
|
584
|
-
|
|
585
|
-
return join(root, BUCKETS_DIRECTORY, hash.slice(0, 2), hash.slice(2, 4), `${hash}.json`);
|
|
600
|
+
function getBucketPathForKeyHash(root, keyHash) {
|
|
601
|
+
return join(root, BUCKETS_DIRECTORY, keyHash.slice(0, 2), keyHash.slice(2, 4), `${keyHash}.json`);
|
|
586
602
|
}
|
|
587
603
|
function serializeBucket(bucket) {
|
|
588
604
|
return JSON.stringify({
|
|
@@ -734,12 +750,42 @@ async function acquireBucketLock(lockPath, options) {
|
|
|
734
750
|
continue;
|
|
735
751
|
}
|
|
736
752
|
if (Date.now() >= deadline) {
|
|
737
|
-
throw new Error(`[@holo-js/security] Timed out waiting for file rate-limit lock "${lockPath}"
|
|
753
|
+
throw new Error(`[@holo-js/security] Timed out waiting for file rate-limit lock "${lockPath}".`, { cause: error });
|
|
738
754
|
}
|
|
739
755
|
await sleep(options.retryDelayMs);
|
|
740
756
|
}
|
|
741
757
|
}
|
|
742
758
|
}
|
|
759
|
+
async function withBucketLockIfFree(path, options, operation) {
|
|
760
|
+
const lockPath = getBucketLockPath(path);
|
|
761
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
762
|
+
let lock;
|
|
763
|
+
try {
|
|
764
|
+
lock = await tryCreateBucketLock(lockPath);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (error.code !== "EEXIST") {
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
if (!await isBucketLockStale(lockPath, options.timeoutMs) || !await reclaimStaleBucketLock(lockPath, options.timeoutMs)) {
|
|
770
|
+
return void 0;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
lock = await tryCreateBucketLock(lockPath);
|
|
774
|
+
} catch (retryError) {
|
|
775
|
+
if (retryError.code === "EEXIST") {
|
|
776
|
+
return void 0;
|
|
777
|
+
}
|
|
778
|
+
throw retryError;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const heartbeat = startLockHeartbeat(lockPath, options.timeoutMs);
|
|
782
|
+
try {
|
|
783
|
+
return await operation();
|
|
784
|
+
} finally {
|
|
785
|
+
clearInterval(heartbeat);
|
|
786
|
+
await removeOwnedBucketLock(lock);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
743
789
|
async function withBucketLock(path, options, operation) {
|
|
744
790
|
const lockPath = getBucketLockPath(path);
|
|
745
791
|
await mkdir(dirname(lockPath), { recursive: true });
|
|
@@ -752,21 +798,92 @@ async function withBucketLock(path, options, operation) {
|
|
|
752
798
|
await removeOwnedBucketLock(acquiredLock);
|
|
753
799
|
}
|
|
754
800
|
}
|
|
755
|
-
|
|
756
|
-
|
|
801
|
+
var UNIT_STEP = { kind: "unit" };
|
|
802
|
+
function* chargeUnits(count) {
|
|
803
|
+
for (let index = 0; index < count; index += 1) {
|
|
804
|
+
yield UNIT_STEP;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
async function readShardEntries(directory, onUnreadable) {
|
|
808
|
+
try {
|
|
809
|
+
return await readdir(directory, { withFileTypes: true });
|
|
810
|
+
} catch (error) {
|
|
757
811
|
if (error.code === "ENOENT") {
|
|
758
|
-
return
|
|
812
|
+
return void 0;
|
|
759
813
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
814
|
+
if (!onUnreadable) {
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
onUnreadable(directory, error);
|
|
818
|
+
return void 0;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async function* readShardListing(directory, onUnreadable) {
|
|
822
|
+
yield UNIT_STEP;
|
|
823
|
+
const entries = await readShardEntries(directory, onUnreadable);
|
|
824
|
+
yield* chargeUnits(entries?.length ?? 0);
|
|
825
|
+
yield UNIT_STEP;
|
|
826
|
+
if (!entries) {
|
|
827
|
+
return void 0;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
names: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async function openShard(directory, onUnreadable) {
|
|
834
|
+
try {
|
|
835
|
+
return await opendir(directory);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
if (error.code === "ENOENT") {
|
|
838
|
+
return void 0;
|
|
839
|
+
}
|
|
840
|
+
if (!onUnreadable) {
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
onUnreadable(directory, error);
|
|
844
|
+
return void 0;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async function readShardEntry(handle, directory, onUnreadable) {
|
|
848
|
+
try {
|
|
849
|
+
return await handle.read();
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (!onUnreadable) {
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
onUnreadable(directory, error);
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
async function* walkBucketTree(bucketsRoot, onUnreadable) {
|
|
859
|
+
const tops = yield* readShardListing(bucketsRoot, onUnreadable);
|
|
860
|
+
for (const top of tops?.names ?? []) {
|
|
861
|
+
const topPath = join(bucketsRoot, top);
|
|
862
|
+
const middles = yield* readShardListing(topPath, onUnreadable);
|
|
863
|
+
for (const middle of middles?.names ?? []) {
|
|
864
|
+
const leaf = join(topPath, middle);
|
|
865
|
+
yield UNIT_STEP;
|
|
866
|
+
const handle = await openShard(leaf, onUnreadable);
|
|
867
|
+
if (!handle) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
for (let entry = await readShardEntry(handle, leaf, onUnreadable); entry !== null; entry = await readShardEntry(handle, leaf, onUnreadable)) {
|
|
872
|
+
yield entry.isFile() && entry.name.endsWith(".json") ? { kind: "bucket", path: join(leaf, entry.name) } : UNIT_STEP;
|
|
873
|
+
}
|
|
874
|
+
} finally {
|
|
875
|
+
await handle.close().catch(() => void 0);
|
|
876
|
+
}
|
|
877
|
+
yield UNIT_STEP;
|
|
766
878
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function* bucketFilePaths(bucketsRoot) {
|
|
882
|
+
for await (const step of walkBucketTree(bucketsRoot)) {
|
|
883
|
+
if (step.kind === "bucket") {
|
|
884
|
+
yield step.path;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
770
887
|
}
|
|
771
888
|
function createSnapshot(key, bucket, maxAttempts) {
|
|
772
889
|
const expiresAt = new Date(bucket.expiresAt.getTime());
|
|
@@ -783,16 +900,96 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
783
900
|
const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
784
901
|
const lockRetryDelayMs = options.lockRetryDelayMs ?? 5;
|
|
785
902
|
const lockTimeoutMs = options.lockTimeoutMs ?? 2e3;
|
|
786
|
-
|
|
903
|
+
const lockOptions = {
|
|
904
|
+
retryDelayMs: lockRetryDelayMs,
|
|
905
|
+
timeoutMs: lockTimeoutMs
|
|
906
|
+
};
|
|
907
|
+
const bucketsRoot = join(root, BUCKETS_DIRECTORY);
|
|
908
|
+
let cycleStartedAt;
|
|
909
|
+
let cursor;
|
|
910
|
+
let inFlight;
|
|
911
|
+
let closed = false;
|
|
912
|
+
const reportSweepError = (error) => {
|
|
913
|
+
reportError(error, { capability: "security", operation: "rate-limit expired-bucket sweep" });
|
|
914
|
+
};
|
|
915
|
+
const reportedUnreadable = /* @__PURE__ */ new Set();
|
|
916
|
+
const reportUnreadable = (directory, error) => {
|
|
917
|
+
if (reportedUnreadable.has(directory)) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
reportedUnreadable.add(directory);
|
|
921
|
+
reportSweepError(error);
|
|
922
|
+
};
|
|
923
|
+
const sweepBucket = async (path, now) => {
|
|
924
|
+
try {
|
|
925
|
+
const candidate = await readBucket(path);
|
|
926
|
+
if (!candidate || !isExpired(candidate, now)) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
await withBucketLockIfFree(path, { timeoutMs: lockTimeoutMs }, async () => {
|
|
930
|
+
const bucket = await readBucket(path);
|
|
931
|
+
if (bucket && isExpired(bucket, now)) {
|
|
932
|
+
await deleteBucket(path);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
} catch (error) {
|
|
936
|
+
reportSweepError(error);
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
const closeCursor = async () => {
|
|
940
|
+
const current = cursor;
|
|
941
|
+
cursor = void 0;
|
|
942
|
+
await current?.return(void 0);
|
|
943
|
+
};
|
|
944
|
+
const runSweep = async (now) => {
|
|
945
|
+
let units = 0;
|
|
946
|
+
let finished = false;
|
|
947
|
+
try {
|
|
948
|
+
cursor ??= walkBucketTree(bucketsRoot, reportUnreadable);
|
|
949
|
+
while (units < SWEEP_BATCH_SIZE && !closed) {
|
|
950
|
+
const next = await cursor.next();
|
|
951
|
+
if (next.done) {
|
|
952
|
+
finished = true;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
units += 1;
|
|
956
|
+
if (next.value.kind === "bucket") {
|
|
957
|
+
await sweepBucket(next.value.path, now);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
} catch (error) {
|
|
961
|
+
reportSweepError(error);
|
|
962
|
+
finished = true;
|
|
963
|
+
} finally {
|
|
964
|
+
if (finished || closed) {
|
|
965
|
+
await closeCursor();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
const startSweepIfDue = (now) => {
|
|
970
|
+
if (closed || inFlight !== void 0) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (cursor === void 0) {
|
|
974
|
+
if (cycleStartedAt !== void 0 && now.getTime() - cycleStartedAt < PRUNE_INTERVAL_MS) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
cycleStartedAt = now.getTime();
|
|
978
|
+
}
|
|
979
|
+
inFlight = runSweep(now).finally(() => {
|
|
980
|
+
inFlight = void 0;
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
const store = {
|
|
787
984
|
async hit(key, hitOptions) {
|
|
788
|
-
const
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}, async () => {
|
|
985
|
+
const sweepNow = resolveNow();
|
|
986
|
+
const keyHash = createBucketHash(key);
|
|
987
|
+
const path = getBucketPathForKeyHash(root, keyHash);
|
|
988
|
+
startSweepIfDue(sweepNow);
|
|
989
|
+
return await withBucketLock(path, lockOptions, async () => {
|
|
794
990
|
const existing = await readBucket(path);
|
|
795
|
-
|
|
991
|
+
const now = resolveNow();
|
|
992
|
+
if (existing && existing.keyHash !== keyHash) {
|
|
796
993
|
throw new Error(`[@holo-js/security] File rate-limit bucket hash collision detected for stored bucket ${existing.keyHash}.`);
|
|
797
994
|
}
|
|
798
995
|
if (existing && isExpired(existing, now)) {
|
|
@@ -802,9 +999,8 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
802
999
|
...existing,
|
|
803
1000
|
attempts: existing.attempts + 1
|
|
804
1001
|
} : {
|
|
805
|
-
|
|
806
|
-
keyHash
|
|
807
|
-
prefixHashes: Object.freeze(createBucketPrefixHashes(key)),
|
|
1002
|
+
...hashBucketKey(key, keyHash),
|
|
1003
|
+
keyHash,
|
|
808
1004
|
attempts: 1,
|
|
809
1005
|
expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
|
|
810
1006
|
};
|
|
@@ -817,13 +1013,11 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
817
1013
|
});
|
|
818
1014
|
},
|
|
819
1015
|
async clear(key) {
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
timeoutMs: lockTimeoutMs
|
|
824
|
-
}, async () => {
|
|
1016
|
+
const keyHash = createBucketHash(key);
|
|
1017
|
+
const path = getBucketPathForKeyHash(root, keyHash);
|
|
1018
|
+
return await withBucketLock(path, lockOptions, async () => {
|
|
825
1019
|
const existing = await readBucket(path);
|
|
826
|
-
if (!existing || existing.keyHash !==
|
|
1020
|
+
if (!existing || existing.keyHash !== keyHash) {
|
|
827
1021
|
return false;
|
|
828
1022
|
}
|
|
829
1023
|
await deleteBucket(path);
|
|
@@ -833,12 +1027,9 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
833
1027
|
async clearByPrefix(prefix) {
|
|
834
1028
|
let cleared = 0;
|
|
835
1029
|
const now = resolveNow();
|
|
836
|
-
const
|
|
837
|
-
for (const path of
|
|
838
|
-
const removed = await withBucketLock(path, {
|
|
839
|
-
retryDelayMs: lockRetryDelayMs,
|
|
840
|
-
timeoutMs: lockTimeoutMs
|
|
841
|
-
}, async () => {
|
|
1030
|
+
const prefixHash = createBucketHash(prefix);
|
|
1031
|
+
for await (const path of bucketFilePaths(bucketsRoot)) {
|
|
1032
|
+
const removed = await withBucketLock(path, lockOptions, async () => {
|
|
842
1033
|
const bucket = await readBucket(path);
|
|
843
1034
|
if (!bucket) {
|
|
844
1035
|
return false;
|
|
@@ -847,7 +1038,7 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
847
1038
|
await deleteBucket(path);
|
|
848
1039
|
return false;
|
|
849
1040
|
}
|
|
850
|
-
if (!bucket.prefixHashes.includes(
|
|
1041
|
+
if (!bucket.prefixHashes.includes(prefixHash)) {
|
|
851
1042
|
return false;
|
|
852
1043
|
}
|
|
853
1044
|
await deleteBucket(path);
|
|
@@ -860,13 +1051,9 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
860
1051
|
return cleared;
|
|
861
1052
|
},
|
|
862
1053
|
async clearAll() {
|
|
863
|
-
const paths = await listBucketPaths(root);
|
|
864
1054
|
let cleared = 0;
|
|
865
|
-
for (const path of
|
|
866
|
-
const removed = await withBucketLock(path, {
|
|
867
|
-
retryDelayMs: lockRetryDelayMs,
|
|
868
|
-
timeoutMs: lockTimeoutMs
|
|
869
|
-
}, async () => {
|
|
1055
|
+
for await (const path of bucketFilePaths(bucketsRoot)) {
|
|
1056
|
+
const removed = await withBucketLock(path, lockOptions, async () => {
|
|
870
1057
|
const bucket = await readBucket(path);
|
|
871
1058
|
if (!bucket) {
|
|
872
1059
|
return false;
|
|
@@ -879,29 +1066,19 @@ function createFileRateLimitStore(root, options = {}) {
|
|
|
879
1066
|
}
|
|
880
1067
|
}
|
|
881
1068
|
return cleared;
|
|
1069
|
+
},
|
|
1070
|
+
async close() {
|
|
1071
|
+
closed = true;
|
|
1072
|
+
await inFlight;
|
|
1073
|
+
await closeCursor();
|
|
882
1074
|
}
|
|
883
1075
|
};
|
|
1076
|
+
return store;
|
|
884
1077
|
}
|
|
885
|
-
var fileRateLimitDriverInternals = {
|
|
886
|
-
createBucketHash,
|
|
887
|
-
createSnapshot,
|
|
888
|
-
deleteBucket,
|
|
889
|
-
deserializeBucket,
|
|
890
|
-
getBucketPath,
|
|
891
|
-
isExpired,
|
|
892
|
-
listBucketPaths,
|
|
893
|
-
readBucket,
|
|
894
|
-
serializeBucket,
|
|
895
|
-
sleep,
|
|
896
|
-
getBucketLockPath,
|
|
897
|
-
acquireBucketLock,
|
|
898
|
-
reclaimStaleBucketLock,
|
|
899
|
-
removeOwnedBucketLock,
|
|
900
|
-
withBucketLock,
|
|
901
|
-
writeBucket
|
|
902
|
-
};
|
|
903
1078
|
|
|
904
1079
|
// src/drivers/memory.ts
|
|
1080
|
+
import { reportError as reportError2 } from "@holo-js/kernel/error-reporting";
|
|
1081
|
+
var DEFAULT_MEMORY_MAX_BUCKETS = 1e4;
|
|
905
1082
|
function createSnapshot2(key, bucket, maxAttempts) {
|
|
906
1083
|
const expiresAt = new Date(bucket.expiresAt.getTime());
|
|
907
1084
|
return Object.freeze({
|
|
@@ -919,27 +1096,41 @@ function isExpired2(bucket, now) {
|
|
|
919
1096
|
function createMemoryRateLimitStore(options = {}) {
|
|
920
1097
|
const buckets = /* @__PURE__ */ new Map();
|
|
921
1098
|
const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
922
|
-
const maxBuckets = options.maxBuckets;
|
|
923
|
-
if (
|
|
1099
|
+
const maxBuckets = options.maxBuckets ?? DEFAULT_MEMORY_MAX_BUCKETS;
|
|
1100
|
+
if (!Number.isInteger(maxBuckets) || maxBuckets < 1) {
|
|
924
1101
|
throw new TypeError("[@holo-js/security] Memory rate-limit store maxBuckets must be an integer greater than or equal to 1.");
|
|
925
1102
|
}
|
|
926
1103
|
const pruneIntervalMs = typeof options.pruneIntervalMs === "number" ? options.pruneIntervalMs : 6e4;
|
|
927
1104
|
if (!Number.isInteger(pruneIntervalMs) || pruneIntervalMs < 1) {
|
|
928
1105
|
throw new TypeError("[@holo-js/security] Memory rate-limit store pruneIntervalMs must be an integer greater than or equal to 1.");
|
|
929
1106
|
}
|
|
930
|
-
|
|
931
|
-
|
|
1107
|
+
let nextExpiryAt = Number.POSITIVE_INFINITY;
|
|
1108
|
+
const refreshNextExpiry = () => {
|
|
1109
|
+
nextExpiryAt = Number.POSITIVE_INFINITY;
|
|
1110
|
+
for (const bucket of buckets.values()) {
|
|
1111
|
+
nextExpiryAt = Math.min(nextExpiryAt, bucket.expiresAt.getTime());
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
const pruneExpiredBuckets = (now) => {
|
|
932
1115
|
for (const [key, bucket] of buckets) {
|
|
933
1116
|
if (isExpired2(bucket, now)) {
|
|
934
1117
|
buckets.delete(key);
|
|
935
1118
|
}
|
|
936
1119
|
}
|
|
1120
|
+
refreshNextExpiry();
|
|
937
1121
|
};
|
|
938
|
-
const pruneTimer = setInterval(pruneExpiredBuckets, pruneIntervalMs);
|
|
1122
|
+
const pruneTimer = setInterval(() => pruneExpiredBuckets(resolveNow()), pruneIntervalMs);
|
|
939
1123
|
pruneTimer.unref?.();
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1124
|
+
let capacityReportedFor;
|
|
1125
|
+
const reportCapacity = (freesAt) => {
|
|
1126
|
+
if (capacityReportedFor === freesAt) {
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
capacityReportedFor = freesAt;
|
|
1130
|
+
reportError2(
|
|
1131
|
+
new Error(`[@holo-js/security] Memory rate-limit store is at capacity (${maxBuckets} keys). New keys are limited until buckets expire. Use the redis or file rate-limit driver for high-cardinality limiters.`),
|
|
1132
|
+
{ capability: "security", operation: "rate-limit memory store at capacity" }
|
|
1133
|
+
);
|
|
943
1134
|
};
|
|
944
1135
|
return {
|
|
945
1136
|
async hit(key, hitOptions) {
|
|
@@ -957,15 +1148,29 @@ function createMemoryRateLimitStore(options = {}) {
|
|
|
957
1148
|
attempts: 1,
|
|
958
1149
|
expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
|
|
959
1150
|
};
|
|
960
|
-
if (!active &&
|
|
961
|
-
|
|
962
|
-
|
|
1151
|
+
if (!active && buckets.size >= maxBuckets) {
|
|
1152
|
+
if (now.getTime() >= nextExpiryAt) {
|
|
1153
|
+
pruneExpiredBuckets(now);
|
|
1154
|
+
}
|
|
1155
|
+
if (buckets.size >= maxBuckets) {
|
|
1156
|
+
const freesAt = nextExpiryAt;
|
|
1157
|
+
reportCapacity(freesAt);
|
|
1158
|
+
return Object.freeze({
|
|
1159
|
+
limited: true,
|
|
1160
|
+
snapshot: createSnapshot2(key, {
|
|
1161
|
+
key,
|
|
1162
|
+
attempts: hitOptions.maxAttempts + 1,
|
|
1163
|
+
expiresAt: new Date(freesAt)
|
|
1164
|
+
}, hitOptions.maxAttempts),
|
|
1165
|
+
retryAfterSeconds: Math.max(0, Math.ceil((freesAt - now.getTime()) / 1e3))
|
|
1166
|
+
});
|
|
963
1167
|
}
|
|
964
1168
|
}
|
|
965
1169
|
if (active) {
|
|
966
1170
|
buckets.delete(key);
|
|
967
1171
|
}
|
|
968
1172
|
buckets.set(key, bucket);
|
|
1173
|
+
nextExpiryAt = Math.min(nextExpiryAt, bucket.expiresAt.getTime());
|
|
969
1174
|
return Object.freeze({
|
|
970
1175
|
limited: bucket.attempts > hitOptions.maxAttempts,
|
|
971
1176
|
snapshot: createSnapshot2(key, bucket, hitOptions.maxAttempts),
|
|
@@ -973,7 +1178,9 @@ function createMemoryRateLimitStore(options = {}) {
|
|
|
973
1178
|
});
|
|
974
1179
|
},
|
|
975
1180
|
async clear(key) {
|
|
976
|
-
|
|
1181
|
+
const deleted = buckets.delete(key);
|
|
1182
|
+
refreshNextExpiry();
|
|
1183
|
+
return deleted;
|
|
977
1184
|
},
|
|
978
1185
|
async clearByPrefix(prefix) {
|
|
979
1186
|
let cleared = 0;
|
|
@@ -984,11 +1191,13 @@ function createMemoryRateLimitStore(options = {}) {
|
|
|
984
1191
|
buckets.delete(key);
|
|
985
1192
|
cleared += 1;
|
|
986
1193
|
}
|
|
1194
|
+
refreshNextExpiry();
|
|
987
1195
|
return cleared;
|
|
988
1196
|
},
|
|
989
1197
|
async clearAll() {
|
|
990
1198
|
const cleared = buckets.size;
|
|
991
1199
|
buckets.clear();
|
|
1200
|
+
refreshNextExpiry();
|
|
992
1201
|
return cleared;
|
|
993
1202
|
},
|
|
994
1203
|
async close() {
|
|
@@ -1094,7 +1303,7 @@ var securityStoreInternals = {
|
|
|
1094
1303
|
};
|
|
1095
1304
|
|
|
1096
1305
|
// src/signed-tokens.ts
|
|
1097
|
-
import { createHmac
|
|
1306
|
+
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1098
1307
|
var MAX_SIGNED_TOKEN_DEPTH = 32;
|
|
1099
1308
|
var MAX_SIGNED_TOKEN_BODY_BYTES = 64 * 1024;
|
|
1100
1309
|
var MAX_SIGNED_TOKEN_CHARACTERS = Math.ceil(MAX_SIGNED_TOKEN_BODY_BYTES * 4 / 3) + 44;
|
|
@@ -1146,7 +1355,7 @@ function assertPayload(payload) {
|
|
|
1146
1355
|
}
|
|
1147
1356
|
}
|
|
1148
1357
|
function signature(body, purpose) {
|
|
1149
|
-
return
|
|
1358
|
+
return createHmac("sha256", signingKey()).update("holo-signed-token-v1\0").update(purpose).update("\0").update(body).digest();
|
|
1150
1359
|
}
|
|
1151
1360
|
function decodeEnvelope(body) {
|
|
1152
1361
|
try {
|
|
@@ -1222,6 +1431,7 @@ export {
|
|
|
1222
1431
|
getSecurityRuntimeBindings,
|
|
1223
1432
|
resetSecurityRuntime,
|
|
1224
1433
|
securityRuntimeInternals,
|
|
1434
|
+
ip,
|
|
1225
1435
|
defaultRateLimitKey,
|
|
1226
1436
|
rateLimit,
|
|
1227
1437
|
clearRateLimit,
|
|
@@ -1230,6 +1440,7 @@ export {
|
|
|
1230
1440
|
token,
|
|
1231
1441
|
field,
|
|
1232
1442
|
input,
|
|
1443
|
+
encryptedToken,
|
|
1233
1444
|
cookie,
|
|
1234
1445
|
verify,
|
|
1235
1446
|
protect,
|
|
@@ -1241,7 +1452,6 @@ export {
|
|
|
1241
1452
|
cors,
|
|
1242
1453
|
corsInternals,
|
|
1243
1454
|
createFileRateLimitStore,
|
|
1244
|
-
fileRateLimitDriverInternals,
|
|
1245
1455
|
createMemoryRateLimitStore,
|
|
1246
1456
|
memoryRateLimitDriverInternals,
|
|
1247
1457
|
createRedisRateLimitStore,
|