@gatwech/auth 1.0.0-beta.19 → 1.0.0-beta.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/dist/adapters/drizzle-adapter/index.mjs +11 -2
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/kysely-adapter/index.mjs +1 -1
- package/dist/adapters/memory-adapter/index.d.mts +1 -1
- package/dist/adapters/memory-adapter/index.mjs +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.mjs +1 -1
- package/dist/{audit-log-DKsUWR8O.mjs → audit-log-DgqHJ-uv.mjs} +132 -63
- package/dist/{auth-vfCyIIeV.mjs → auth-BoEA9bN7.mjs} +14 -1
- package/dist/client/index.d.mts +18 -18
- package/dist/client/plugins/index.d.mts +28 -28
- package/dist/client/plugins/index.mjs +2 -2
- package/dist/client/react/index.d.mts +16 -16
- package/dist/{client-CSOs1YN7.d.mts → client-B00gkznv.d.mts} +1 -1
- package/dist/{client-4QEb0rUr.mjs → client-CMsC6-rl.mjs} +1 -1
- package/dist/{client-DZHNfxuR.d.mts → client-Da7tq8m6.d.mts} +4 -4
- package/dist/cookies/index.d.mts +3 -3
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +1 -1
- package/dist/{db-7vHnL1XA.mjs → db-CV59HpEF.mjs} +2 -2
- package/dist/{index-mxEUX2Gs.d.mts → index-BECNG13R.d.mts} +13 -13
- package/dist/{index-DRM35ir1.d.mts → index-BKy-8t2n.d.mts} +118 -65
- package/dist/{index-Df0KhQRf-BEa5tdwJ.d.mts → index-BL_t8cRp-C4QYBsac.d.mts} +56 -17
- package/dist/{index-DXVU0HGr.d.mts → index-BWYXZVsV.d.mts} +5 -5
- package/dist/{index-Df4SvZaE.d.mts → index-Bhc37y9F.d.mts} +3 -3
- package/dist/{index-BNhJglHC.d.mts → index-BjCQ3jmE.d.mts} +119 -119
- package/dist/{index-Cv9iMevV.d.mts → index-Bxive_YT.d.mts} +9 -9
- package/dist/{index-CgeC2eGC.d.mts → index-C-IDh5yy.d.mts} +2 -2
- package/dist/{index-COGe8UJx.d.mts → index-CM1rFdwV.d.mts} +9 -9
- package/dist/{index-zBdYQGaS.d.mts → index-CMG4Pnen.d.mts} +6 -6
- package/dist/{index-Blmhq6eA.d.mts → index-CTqynuxm.d.mts} +4 -4
- package/dist/{index-DS-R7st3.d.mts → index-CbBQdS-u.d.mts} +6 -6
- package/dist/{index-Db8jqUq2.d.mts → index-CpH-1sse.d.mts} +10 -10
- package/dist/{index-CUknVRZp.d.mts → index-D1OZ2e4m.d.mts} +14 -14
- package/dist/{index-CaqpCfE5.d.mts → index-Dt2I0G4X.d.mts} +7 -7
- package/dist/{index-EABwA_BH.d.mts → index-IX1to9TB.d.mts} +34 -34
- package/dist/{index-CKnPAS5n.d.mts → index-TrOnY3pM.d.mts} +170 -170
- package/dist/{index-CsxtcAO0.d.mts → index-VUyESU4p.d.mts} +1 -1
- package/dist/{index-CP3FNbOZ.d.mts → index-WV7VzW2H.d.mts} +8 -8
- package/dist/{index-xoYsoOiR.d.mts → index-YeXSxzhX.d.mts} +3 -3
- package/dist/{index-DG-X1PK5.d.mts → index-cPt-00xm.d.mts} +6 -6
- package/dist/{index-BNDRYfTF.d.mts → index-iRoaTsOj.d.mts} +2 -2
- package/dist/{index-BAVzFqII.d.mts → index-jEkJ2Zfk.d.mts} +6 -6
- package/dist/{index-DyH_CgmX.d.mts → index-oNlp4SvD.d.mts} +10 -10
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/integrations/next-js.d.mts +4 -4
- package/dist/integrations/node.d.mts +2 -2
- package/dist/integrations/react-start.d.mts +4 -4
- package/dist/{kysely-adapter-Bkm1liPW.mjs → kysely-adapter-BidlNTiV.mjs} +4 -2
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +1 -1
- package/dist/{organization-CkTKs7bx.mjs → organization-D4QMpChF.mjs} +1 -1
- package/dist/plugins/admin/index.d.mts +3 -3
- package/dist/plugins/admin/index.mjs +1 -1
- package/dist/plugins/anonymous/index.d.mts +3 -3
- package/dist/plugins/anonymous/index.mjs +1 -1
- package/dist/plugins/audit-log/client.d.mts +24 -24
- package/dist/plugins/audit-log/index.d.mts +23 -23
- package/dist/plugins/audit-log/index.mjs +2 -2
- package/dist/plugins/bearer/index.d.mts +1 -1
- package/dist/plugins/captcha/index.d.mts +1 -1
- package/dist/plugins/custom-session/index.d.mts +3 -3
- package/dist/plugins/custom-session/index.mjs +1 -1
- package/dist/plugins/device-authorization/index.d.mts +1 -1
- package/dist/plugins/device-authorization/index.mjs +1 -1
- package/dist/plugins/email-otp/index.d.mts +2 -2
- package/dist/plugins/email-otp/index.mjs +1 -1
- package/dist/plugins/generic-oauth/index.d.mts +3 -3
- package/dist/plugins/generic-oauth/index.mjs +1 -1
- package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
- package/dist/plugins/haveibeenpwned/index.mjs +1 -1
- package/dist/plugins/index.d.mts +23 -23
- package/dist/plugins/index.mjs +5 -5
- package/dist/plugins/jwt/index.d.mts +3 -3
- package/dist/plugins/jwt/index.mjs +1 -1
- package/dist/plugins/magic-link/index.d.mts +1 -1
- package/dist/plugins/magic-link/index.mjs +1 -1
- package/dist/plugins/multi-session/index.d.mts +2 -2
- package/dist/plugins/multi-session/index.mjs +1 -1
- package/dist/plugins/oauth-proxy/index.d.mts +1 -1
- package/dist/plugins/oauth-proxy/index.mjs +1 -1
- package/dist/plugins/one-tap/index.d.mts +1 -1
- package/dist/plugins/one-tap/index.mjs +1 -1
- package/dist/plugins/one-time-token/index.d.mts +3 -3
- package/dist/plugins/one-time-token/index.mjs +5 -5
- package/dist/plugins/organization/index.d.mts +3 -3
- package/dist/plugins/organization/index.mjs +2 -2
- package/dist/plugins/passkey/index.d.mts +3 -3
- package/dist/plugins/passkey/index.mjs +1 -1
- package/dist/plugins/phone-number/index.d.mts +3 -3
- package/dist/plugins/phone-number/index.mjs +1 -1
- package/dist/plugins/sentinel/client.d.mts +24 -24
- package/dist/plugins/sentinel/client.mjs +2 -2
- package/dist/plugins/sentinel/index.d.mts +23 -23
- package/dist/plugins/sentinel/index.mjs +3 -3
- package/dist/plugins/two-factor/index.d.mts +3 -3
- package/dist/plugins/two-factor/index.mjs +1 -1
- package/dist/plugins/username/index.d.mts +1 -1
- package/dist/plugins/username/index.mjs +1 -1
- package/dist/{pow-OK01yPG3.mjs → pow-DvkUopsK.mjs} +17 -1
- package/dist/{sentinel-C-D1IHJo.mjs → sentinel-B4Wo79HK.mjs} +326 -92
- package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
- package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
- package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
- package/dist/src/adapters/tests/normal.d.ts +10 -2
- package/dist/src/adapters/tests/number-id.d.ts +4 -4
- package/dist/src/adapters/tests/performance.d.ts +3 -3
- package/dist/src/adapters/tests/transactions.d.ts +3 -3
- package/dist/src/api/index.d.ts +8 -8
- package/dist/src/plugins/admin/admin.d.ts +2 -2
- package/dist/src/plugins/api-key/index.d.ts +2 -2
- package/dist/src/plugins/audit-log/index.d.ts +5 -14
- package/dist/src/plugins/audit-log/remote.d.ts +4 -4
- package/dist/src/plugins/audit-log/types.d.ts +22 -0
- package/dist/src/plugins/email-otp/index.d.ts +1 -1
- package/dist/src/plugins/multi-session/index.d.ts +8 -8
- package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
- package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
- package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
- package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
- package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
- package/dist/src/plugins/sentinel/email.d.ts +9 -3
- package/dist/src/plugins/sentinel/index.d.ts +10 -1
- package/dist/src/plugins/sentinel/intel.d.ts +8 -1
- package/dist/src/plugins/sentinel/memory-cache.d.ts +12 -0
- package/dist/src/plugins/sentinel/schema.d.ts +4 -0
- package/dist/src/plugins/sentinel/store.d.ts +7 -0
- package/dist/src/plugins/sentinel/types.d.ts +29 -1
- package/dist/src/test-utils/test-instance.d.ts +25 -25
- package/dist/test-utils/index.d.mts +293 -293
- package/dist/test-utils/index.mjs +7 -7
- package/dist/types/index.d.mts +2 -2
- package/package.json +3 -3
|
@@ -4,7 +4,7 @@ import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
|
|
|
4
4
|
import { n as mergeSchema } from "./date-CMr04reD.mjs";
|
|
5
5
|
import { a as CREDENTIAL_SIGN_IN_PATHS, c as SIGN_IN_PATHS, l as VERIFICATION_PATHS, s as SECURITY_EVENT_TYPES, t as getAuditRecorder } from "./registry-CqG_3Qnx.mjs";
|
|
6
6
|
import { t as getPasswordBreachCount } from "./haveibeenpwned-CzNda7Tn.mjs";
|
|
7
|
-
import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-
|
|
7
|
+
import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-DvkUopsK.mjs";
|
|
8
8
|
import { env } from "@gatwech/core/env";
|
|
9
9
|
import { PayvoAuthError } from "@gatwech/core/error";
|
|
10
10
|
import { defineErrorCodes } from "@gatwech/core/utils";
|
|
@@ -15,15 +15,20 @@ import { betterFetch } from "@better-fetch/fetch";
|
|
|
15
15
|
//#region src/plugins/sentinel/actions.ts
|
|
16
16
|
const CHALLENGE_TTL_MS = 6e4;
|
|
17
17
|
/**
|
|
18
|
+
* Challenges an IP address can be issued within one challenge lifetime.
|
|
19
|
+
* Each one is stored, so past this the request is blocked instead.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_CHALLENGES_PER_IP = 30;
|
|
22
|
+
/**
|
|
18
23
|
* Applies a security action: records the event and decides whether the
|
|
19
24
|
* request continues, gets a proof-of-work challenge or is blocked
|
|
20
25
|
*/
|
|
21
26
|
async function executeAction(params) {
|
|
22
27
|
const { auth, event, action, reason } = params;
|
|
23
28
|
const recorder = getAuditRecorder(auth);
|
|
24
|
-
const record = async (eventType, status) => {
|
|
29
|
+
const record = async (eventType, status, metadata = params.metadata) => {
|
|
25
30
|
if (!recorder) {
|
|
26
|
-
auth.logger.warn(`[sentinel] ${reason}: ${status}`,
|
|
31
|
+
auth.logger.warn(`[sentinel] ${reason}: ${status}`, metadata);
|
|
27
32
|
return;
|
|
28
33
|
}
|
|
29
34
|
await recorder(event, {
|
|
@@ -36,7 +41,7 @@ async function executeAction(params) {
|
|
|
36
41
|
eventData: {
|
|
37
42
|
reason,
|
|
38
43
|
action,
|
|
39
|
-
...
|
|
44
|
+
...metadata
|
|
40
45
|
}
|
|
41
46
|
});
|
|
42
47
|
};
|
|
@@ -49,12 +54,32 @@ async function executeAction(params) {
|
|
|
49
54
|
await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
|
|
50
55
|
return { outcome: "allow" };
|
|
51
56
|
}
|
|
57
|
+
if (params.ipAddress) {
|
|
58
|
+
const challengesIssued = await params.store.increment(`pow-issued:${params.ipAddress}`, CHALLENGE_TTL_MS / 1e3).catch((e) => {
|
|
59
|
+
auth.logger.error("[sentinel] Failed to count challenges", e);
|
|
60
|
+
return 0;
|
|
61
|
+
});
|
|
62
|
+
if (challengesIssued > MAX_CHALLENGES_PER_IP) {
|
|
63
|
+
const metadata = {
|
|
64
|
+
...params.metadata,
|
|
65
|
+
challengesIssued,
|
|
66
|
+
maxChallenges: MAX_CHALLENGES_PER_IP
|
|
67
|
+
};
|
|
68
|
+
await record(params.eventType, "blocked", metadata);
|
|
69
|
+
await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked", metadata);
|
|
70
|
+
return {
|
|
71
|
+
outcome: "block",
|
|
72
|
+
reason
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
52
76
|
const nonce = createNonce();
|
|
53
77
|
try {
|
|
54
78
|
await params.store.saveChallenge({
|
|
55
79
|
nonce,
|
|
56
80
|
difficulty: params.difficulty,
|
|
57
81
|
reason,
|
|
82
|
+
path: event.path ?? null,
|
|
58
83
|
ipAddress: params.ipAddress,
|
|
59
84
|
visitorId: params.visitorId,
|
|
60
85
|
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
|
|
@@ -194,23 +219,54 @@ async function isDisposableDomain(domain) {
|
|
|
194
219
|
for (let i = 0; i < labels.length - 1; i++) if (isDisposable(labels.slice(i).join("."))) return true;
|
|
195
220
|
return false;
|
|
196
221
|
}
|
|
222
|
+
/** How long a mail lookup may take before it counts as unknown */
|
|
223
|
+
const MAIL_LOOKUP_TIMEOUT_MS = 2e3;
|
|
224
|
+
/** The lookup found nothing, as opposed to failing */
|
|
225
|
+
const isMissingRecord = (e) => {
|
|
226
|
+
const code = e.code;
|
|
227
|
+
return code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP";
|
|
228
|
+
};
|
|
229
|
+
/** Whether a domain has an A or AAAA record, null when a lookup failed */
|
|
230
|
+
async function hasAddressRecords(dns, domain) {
|
|
231
|
+
const addressLookups = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
|
|
232
|
+
if (addressLookups.some((lookup) => lookup.status === "fulfilled" && lookup.value.length > 0)) return true;
|
|
233
|
+
return addressLookups.some((lookup) => lookup.status === "rejected" && !isMissingRecord(lookup.reason)) ? null : false;
|
|
234
|
+
}
|
|
235
|
+
async function canReceiveMail(dns, domain) {
|
|
236
|
+
try {
|
|
237
|
+
const mxRecords = await dns.resolveMx(domain);
|
|
238
|
+
if (mxRecords.length === 1 && (mxRecords[0].exchange === "" || mxRecords[0].exchange === ".")) return false;
|
|
239
|
+
if (mxRecords.length > 0) return true;
|
|
240
|
+
} catch (e) {
|
|
241
|
+
const code = e.code;
|
|
242
|
+
if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
|
|
243
|
+
if (!isMissingRecord(e)) return null;
|
|
244
|
+
}
|
|
245
|
+
return hasAddressRecords(dns, domain);
|
|
246
|
+
}
|
|
197
247
|
/**
|
|
198
|
-
* Whether a domain
|
|
199
|
-
*
|
|
248
|
+
* Whether a domain can receive mail: it has an MX record, or an address
|
|
249
|
+
* record to fall back to when it has none. A null MX record, which marks a
|
|
250
|
+
* domain that accepts no mail, doesn't count.
|
|
251
|
+
*
|
|
252
|
+
* Returns null when that can't be told: runtimes without DNS access, lookup
|
|
253
|
+
* failures, or a lookup that takes longer than `timeoutMs`.
|
|
200
254
|
*/
|
|
201
|
-
async function hasMxRecords(domain) {
|
|
255
|
+
async function hasMxRecords(domain, options = {}) {
|
|
202
256
|
let dns;
|
|
203
257
|
try {
|
|
204
258
|
dns = await import("node:dns/promises");
|
|
205
259
|
} catch {
|
|
206
260
|
return null;
|
|
207
261
|
}
|
|
262
|
+
let timer;
|
|
263
|
+
const timeout = new Promise((resolve) => {
|
|
264
|
+
timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
|
|
265
|
+
});
|
|
208
266
|
try {
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP") return false;
|
|
213
|
-
return null;
|
|
267
|
+
return await Promise.race([canReceiveMail(dns, domain), timeout]);
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
214
270
|
}
|
|
215
271
|
}
|
|
216
272
|
/**
|
|
@@ -247,6 +303,41 @@ function travelSpeedKmh(from, to) {
|
|
|
247
303
|
return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
|
|
248
304
|
}
|
|
249
305
|
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/plugins/sentinel/memory-cache.ts
|
|
308
|
+
/**
|
|
309
|
+
* A small in-memory cache that forgets entries after a TTL and drops the
|
|
310
|
+
* least recently used one when it's full. Used when there's no secondary
|
|
311
|
+
* storage to share verdicts through.
|
|
312
|
+
*/
|
|
313
|
+
function createMemoryCache(options) {
|
|
314
|
+
const entries = /* @__PURE__ */ new Map();
|
|
315
|
+
return {
|
|
316
|
+
get(key) {
|
|
317
|
+
const entry = entries.get(key);
|
|
318
|
+
if (!entry) return void 0;
|
|
319
|
+
if (entry.expiresAt <= Date.now()) {
|
|
320
|
+
entries.delete(key);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
entries.delete(key);
|
|
324
|
+
entries.set(key, entry);
|
|
325
|
+
return entry.value;
|
|
326
|
+
},
|
|
327
|
+
set(key, value, ttlMs = options.ttlMs) {
|
|
328
|
+
entries.delete(key);
|
|
329
|
+
if (entries.size >= options.maxSize) {
|
|
330
|
+
const oldest = entries.keys().next().value;
|
|
331
|
+
if (oldest !== void 0) entries.delete(oldest);
|
|
332
|
+
}
|
|
333
|
+
entries.set(key, {
|
|
334
|
+
value,
|
|
335
|
+
expiresAt: Date.now() + ttlMs
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
250
341
|
//#endregion
|
|
251
342
|
//#region src/plugins/sentinel/intel.ts
|
|
252
343
|
const BOT_USER_AGENT = /bot|crawl|spider|slurp|headless|phantomjs|selenium|puppeteer|playwright|curl|wget|httpie|python-requests|python-urllib|aiohttp|go-http-client|okhttp|java\/|libwww|scrapy|postman|insomnia/i;
|
|
@@ -265,9 +356,18 @@ const UNKNOWN = {
|
|
|
265
356
|
bot: null,
|
|
266
357
|
geo: null
|
|
267
358
|
};
|
|
359
|
+
/** Verdicts kept in memory when there's no secondary storage */
|
|
360
|
+
const MEMORY_CACHE_SIZE = 5e3;
|
|
361
|
+
/** How long a verified visitor id is remembered */
|
|
362
|
+
const VISITOR_CACHE_SECONDS = 300;
|
|
363
|
+
/** How long an in-memory request verdict lasts when the backend sets none */
|
|
364
|
+
const DEFAULT_INTEL_CACHE_SECONDS = 60;
|
|
365
|
+
/** The longest an in-memory request verdict lasts */
|
|
366
|
+
const MAX_INTEL_CACHE_SECONDS = 300;
|
|
268
367
|
/**
|
|
269
368
|
* Asks the hosted backend about a request's IP address and client. Verdicts
|
|
270
|
-
* are cached
|
|
369
|
+
* are cached for as long as the backend allows, in secondary storage or, when
|
|
370
|
+
* there is none, in memory.
|
|
271
371
|
* Returns null verdicts when the backend isn't configured or can't answer.
|
|
272
372
|
*/
|
|
273
373
|
function createIntelClient(options) {
|
|
@@ -281,15 +381,26 @@ function createIntelClient(options) {
|
|
|
281
381
|
baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
|
|
282
382
|
maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
|
|
283
383
|
};
|
|
384
|
+
const visitorVerdicts = createMemoryCache({
|
|
385
|
+
maxSize: MEMORY_CACHE_SIZE,
|
|
386
|
+
ttlMs: VISITOR_CACHE_SECONDS * 1e3
|
|
387
|
+
});
|
|
388
|
+
const requestVerdicts = createMemoryCache({
|
|
389
|
+
maxSize: MEMORY_CACHE_SIZE,
|
|
390
|
+
ttlMs: DEFAULT_INTEL_CACHE_SECONDS * 1e3
|
|
391
|
+
});
|
|
284
392
|
return {
|
|
285
393
|
configured,
|
|
286
394
|
verifiesVisitors: Boolean(kvUrl && apiKey),
|
|
287
395
|
misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
|
|
288
|
-
async verifyVisitor(auth, request) {
|
|
396
|
+
async verifyVisitor(auth, request, limit) {
|
|
289
397
|
if (!kvUrl || !apiKey) return request.visitorId;
|
|
290
398
|
const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
|
|
291
399
|
const cached = await auth.secondaryStorage?.get(cacheKey);
|
|
292
400
|
if (typeof cached === "string") return cached || null;
|
|
401
|
+
const remembered = visitorVerdicts.get(cacheKey);
|
|
402
|
+
if (remembered !== void 0 && !auth.secondaryStorage) return remembered;
|
|
403
|
+
if (limit && !await limit.allowed()) return null;
|
|
293
404
|
for (let attempt = 0; attempt <= kv.attempts; attempt++) {
|
|
294
405
|
if (attempt > 0) {
|
|
295
406
|
const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
|
|
@@ -309,7 +420,9 @@ function createIntelClient(options) {
|
|
|
309
420
|
}));
|
|
310
421
|
if (data && !error) {
|
|
311
422
|
const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
|
|
312
|
-
|
|
423
|
+
if (!visitorId) await limit?.rejected();
|
|
424
|
+
if (auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, visitorId ?? "", VISITOR_CACHE_SECONDS);
|
|
425
|
+
else visitorVerdicts.set(cacheKey, visitorId);
|
|
313
426
|
return visitorId;
|
|
314
427
|
}
|
|
315
428
|
}
|
|
@@ -321,6 +434,8 @@ function createIntelClient(options) {
|
|
|
321
434
|
const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
|
|
322
435
|
const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
|
|
323
436
|
if (cached) return cached;
|
|
437
|
+
const remembered = auth.secondaryStorage ? void 0 : requestVerdicts.get(cacheKey);
|
|
438
|
+
if (remembered) return remembered;
|
|
324
439
|
const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
|
|
325
440
|
method: "POST",
|
|
326
441
|
headers: {
|
|
@@ -347,7 +462,12 @@ function createIntelClient(options) {
|
|
|
347
462
|
bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
|
|
348
463
|
geo: data.geo ?? null
|
|
349
464
|
};
|
|
350
|
-
if (
|
|
465
|
+
if (auth.secondaryStorage) {
|
|
466
|
+
if (data.ttlSeconds) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
|
|
467
|
+
} else {
|
|
468
|
+
const ttlSeconds = Math.min(data.ttlSeconds ?? DEFAULT_INTEL_CACHE_SECONDS, MAX_INTEL_CACHE_SECONDS);
|
|
469
|
+
requestVerdicts.set(cacheKey, intel, ttlSeconds * 1e3);
|
|
470
|
+
}
|
|
351
471
|
return intel;
|
|
352
472
|
}
|
|
353
473
|
};
|
|
@@ -435,6 +555,10 @@ const schema = {
|
|
|
435
555
|
type: "string",
|
|
436
556
|
required: true
|
|
437
557
|
},
|
|
558
|
+
path: {
|
|
559
|
+
type: "string",
|
|
560
|
+
required: false
|
|
561
|
+
},
|
|
438
562
|
ipAddress: {
|
|
439
563
|
type: "string",
|
|
440
564
|
required: false
|
|
@@ -457,6 +581,35 @@ const schema = {
|
|
|
457
581
|
//#endregion
|
|
458
582
|
//#region src/plugins/sentinel/store.ts
|
|
459
583
|
const PREFIX = "sentinel:";
|
|
584
|
+
/** How long after expiring a row is deleted */
|
|
585
|
+
const EXPIRED_ROW_GRACE_MS = 6e4;
|
|
586
|
+
/** Tries to increment a counter in the database before giving up */
|
|
587
|
+
const MAX_COUNTER_ATTEMPTS = 20;
|
|
588
|
+
/** When each auth instance last deleted expired rows, keyed by its adapter */
|
|
589
|
+
const lastChecked = /* @__PURE__ */ new WeakMap();
|
|
590
|
+
/**
|
|
591
|
+
* Deletes expired counters and challenges from the database, at most every
|
|
592
|
+
* 10 seconds per auth instance. Secondary storage expires them itself.
|
|
593
|
+
*/
|
|
594
|
+
async function deleteAllExpiredRows(auth, byPassLastCheckTime = false) {
|
|
595
|
+
if (auth.secondaryStorage) return;
|
|
596
|
+
const checkedAt = lastChecked.get(auth.adapter);
|
|
597
|
+
if (checkedAt && !byPassLastCheckTime) {
|
|
598
|
+
if (Date.now() - checkedAt.getTime() < 1e4) return;
|
|
599
|
+
}
|
|
600
|
+
lastChecked.set(auth.adapter, /* @__PURE__ */ new Date());
|
|
601
|
+
const expiredBefore = new Date(Date.now() - EXPIRED_ROW_GRACE_MS);
|
|
602
|
+
await Promise.all(["sentinelCounter", "powChallenge"].map((model) => auth.adapter.deleteMany({
|
|
603
|
+
model,
|
|
604
|
+
where: [{
|
|
605
|
+
field: "expiresAt",
|
|
606
|
+
operator: "lt",
|
|
607
|
+
value: expiredBefore
|
|
608
|
+
}]
|
|
609
|
+
}))).catch((error) => {
|
|
610
|
+
auth.logger.error("[sentinel] Failed to delete expired rows:", error);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
460
613
|
/**
|
|
461
614
|
* Counters and challenges, kept in secondary storage when it's configured
|
|
462
615
|
* and in the database otherwise
|
|
@@ -491,45 +644,45 @@ function createSentinelStore(auth) {
|
|
|
491
644
|
}), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
|
|
492
645
|
return count;
|
|
493
646
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
647
|
+
deleteAllExpiredRows(auth);
|
|
648
|
+
for (let attempt = 0; attempt < MAX_COUNTER_ATTEMPTS; attempt++) {
|
|
649
|
+
const row = await findCounter(key);
|
|
650
|
+
if (!row) {
|
|
651
|
+
if (await auth.adapter.create({
|
|
652
|
+
model: "sentinelCounter",
|
|
653
|
+
data: {
|
|
654
|
+
key,
|
|
655
|
+
count: 1,
|
|
656
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
657
|
+
}
|
|
658
|
+
}).then(() => true, () => false)) return 1;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const expiresAt = new Date(row.expiresAt);
|
|
662
|
+
const expired = expiresAt.getTime() <= Date.now();
|
|
663
|
+
if (await auth.adapter.updateMany({
|
|
509
664
|
model: "sentinelCounter",
|
|
510
|
-
where: [
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
665
|
+
where: [
|
|
666
|
+
{
|
|
667
|
+
field: "id",
|
|
668
|
+
value: row.id
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
field: "count",
|
|
672
|
+
value: row.count
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
field: "expiresAt",
|
|
676
|
+
value: expiresAt
|
|
677
|
+
}
|
|
678
|
+
],
|
|
514
679
|
update: {
|
|
515
|
-
count: 1,
|
|
516
|
-
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
680
|
+
count: expired ? 1 : row.count + 1,
|
|
681
|
+
...expired || options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
|
|
517
682
|
}
|
|
518
|
-
});
|
|
519
|
-
return 1;
|
|
683
|
+
}) > 0) return expired ? 1 : row.count + 1;
|
|
520
684
|
}
|
|
521
|
-
|
|
522
|
-
model: "sentinelCounter",
|
|
523
|
-
where: [{
|
|
524
|
-
field: "id",
|
|
525
|
-
value: row.id
|
|
526
|
-
}],
|
|
527
|
-
update: {
|
|
528
|
-
count: row.count + 1,
|
|
529
|
-
...options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
return row.count + 1;
|
|
685
|
+
throw new Error(`Failed to increment counter ${key}`);
|
|
533
686
|
},
|
|
534
687
|
async get(name) {
|
|
535
688
|
const key = PREFIX + name;
|
|
@@ -564,6 +717,7 @@ function createSentinelStore(auth) {
|
|
|
564
717
|
await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
|
|
565
718
|
return;
|
|
566
719
|
}
|
|
720
|
+
deleteAllExpiredRows(auth);
|
|
567
721
|
await auth.adapter.create({
|
|
568
722
|
model: "powChallenge",
|
|
569
723
|
data: {
|
|
@@ -647,11 +801,26 @@ const NEW_PASSWORD_FIELDS = {
|
|
|
647
801
|
* towards credential stuffing
|
|
648
802
|
*/
|
|
649
803
|
const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
|
|
804
|
+
/** Endpoints a bot is blocked on: sign-in, sign-up and password reset */
|
|
805
|
+
const BOT_CHECK_PATHS = [
|
|
806
|
+
...CREDENTIAL_SIGN_IN_PATHS,
|
|
807
|
+
...VERIFICATION_PATHS,
|
|
808
|
+
...SIGN_UP_PATHS,
|
|
809
|
+
...PASSWORD_RESET_PATHS
|
|
810
|
+
];
|
|
650
811
|
/** Account creation that isn't done by the visitor signing up */
|
|
651
812
|
const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
|
|
652
813
|
/** Social sign-ups, whose email the provider has already verified */
|
|
653
814
|
const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
|
|
654
815
|
const DAY_SECONDS = 1440 * 60;
|
|
816
|
+
/** Window the identification service limits per IP address count over */
|
|
817
|
+
const VISITOR_LIMIT_WINDOW_SECONDS = 60;
|
|
818
|
+
/** Calls to the identification service per IP address in the window */
|
|
819
|
+
const MAX_VERIFICATIONS_PER_IP = 20;
|
|
820
|
+
/** Visitor ids that weren't genuine per IP address in the window */
|
|
821
|
+
const MAX_INVALID_VISITORS_PER_IP = 5;
|
|
822
|
+
/** Sign-ins kept per user for impossible travel, stale and new devices */
|
|
823
|
+
const SIGN_IN_HISTORY_LIMIT = 50;
|
|
655
824
|
/**
|
|
656
825
|
* Sign-ins checked with a password, which can be repeated with a challenge
|
|
657
826
|
* solution
|
|
@@ -701,7 +870,10 @@ const sentinel = (options) => {
|
|
|
701
870
|
thresholds: security.freeTrialAbuse.thresholds,
|
|
702
871
|
maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
|
|
703
872
|
retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
|
|
704
|
-
action: security.freeTrialAbuse.action ?? "block"
|
|
873
|
+
action: security.freeTrialAbuse.action ?? "block",
|
|
874
|
+
maxAccountsPerIp: security.freeTrialAbuse.maxAccountsPerIp ?? 10,
|
|
875
|
+
ipWindowSeconds: Math.min(Math.max(security.freeTrialAbuse.ipWindowSeconds ?? DAY_SECONDS, 1), 365 * DAY_SECONDS),
|
|
876
|
+
requireVerifiedVisitor: security.freeTrialAbuse.requireVerifiedVisitor ?? false
|
|
705
877
|
} : null;
|
|
706
878
|
const botBlocking = resolveToggle(security.botBlocking, "challenge");
|
|
707
879
|
const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
|
|
@@ -735,16 +907,32 @@ const sentinel = (options) => {
|
|
|
735
907
|
const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
|
|
736
908
|
const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
|
|
737
909
|
const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
|
|
738
|
-
/**
|
|
739
|
-
const
|
|
740
|
-
/**
|
|
741
|
-
const
|
|
910
|
+
/** Whether each request's solution was accepted, so it's checked once */
|
|
911
|
+
const acceptedSolutions = /* @__PURE__ */ new WeakMap();
|
|
912
|
+
/** Each request's verified visitor, so it's checked once */
|
|
913
|
+
const verifiedVisitors = /* @__PURE__ */ new WeakMap();
|
|
742
914
|
function clientIp(request, auth) {
|
|
743
915
|
if (!request) return null;
|
|
744
916
|
if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
|
|
745
917
|
return getIp(request, auth.options);
|
|
746
918
|
}
|
|
747
919
|
/**
|
|
920
|
+
* Limits the calls to the identification service for one IP address: after
|
|
921
|
+
* too many calls, or too many ids that weren't genuine, its ids go
|
|
922
|
+
* unchecked and aren't trusted
|
|
923
|
+
*/
|
|
924
|
+
function visitorVerificationLimit(store, ip) {
|
|
925
|
+
return {
|
|
926
|
+
async allowed() {
|
|
927
|
+
if (await store.get(`visitor-invalid:${ip}`) >= MAX_INVALID_VISITORS_PER_IP) return false;
|
|
928
|
+
return await store.increment(`visitor-verify:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS) <= MAX_VERIFICATIONS_PER_IP;
|
|
929
|
+
},
|
|
930
|
+
async rejected() {
|
|
931
|
+
await store.increment(`visitor-invalid:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS);
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
748
936
|
* The visitor id sent by the client, checked with the identification
|
|
749
937
|
* service when one is configured
|
|
750
938
|
*/
|
|
@@ -754,22 +942,23 @@ const sentinel = (options) => {
|
|
|
754
942
|
id: null,
|
|
755
943
|
verified: false
|
|
756
944
|
});
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
945
|
+
const cachedVisitor = verifiedVisitors.get(headers);
|
|
946
|
+
if (cachedVisitor) return cachedVisitor;
|
|
759
947
|
const unchecked = {
|
|
760
948
|
id: opts.failOpen ? sent : null,
|
|
761
949
|
verified: false
|
|
762
950
|
};
|
|
763
|
-
const
|
|
951
|
+
const store = createSentinelStore(auth);
|
|
952
|
+
const visitor = intel.verifyVisitor(auth, {
|
|
764
953
|
visitorId: sent,
|
|
765
954
|
ip,
|
|
766
955
|
userAgent: headers.get("user-agent")
|
|
767
|
-
}).then((verified) => verified === void 0 ? unchecked : {
|
|
956
|
+
}, ip ? visitorVerificationLimit(store, ip) : void 0).then((verified) => verified === void 0 ? unchecked : {
|
|
768
957
|
id: verified,
|
|
769
958
|
verified: intel.verifiesVisitors
|
|
770
959
|
}).catch(() => unchecked);
|
|
771
|
-
|
|
772
|
-
return
|
|
960
|
+
verifiedVisitors.set(headers, visitor);
|
|
961
|
+
return visitor;
|
|
773
962
|
}
|
|
774
963
|
function relativePath(request, auth) {
|
|
775
964
|
const pathname = new URL(request.url).pathname;
|
|
@@ -778,27 +967,28 @@ const sentinel = (options) => {
|
|
|
778
967
|
}
|
|
779
968
|
/**
|
|
780
969
|
* Whether the request carries a valid, unused solution to a challenge
|
|
781
|
-
* issued to the same IP address and visitor
|
|
970
|
+
* issued to the same IP address and visitor, for the same endpoint
|
|
782
971
|
*/
|
|
783
|
-
function challengePassed(key, details, store, auth) {
|
|
784
|
-
const
|
|
785
|
-
if (
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
if (!
|
|
972
|
+
function challengePassed(key, details, store, auth, path) {
|
|
973
|
+
const cachedAcceptance = acceptedSolutions.get(key);
|
|
974
|
+
if (cachedAcceptance) return cachedAcceptance;
|
|
975
|
+
const accepted = (async () => {
|
|
976
|
+
const solution = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
|
|
977
|
+
if (!solution) return false;
|
|
789
978
|
try {
|
|
790
|
-
const challenge = await store.consumeChallenge(
|
|
979
|
+
const challenge = await store.consumeChallenge(solution.nonce);
|
|
791
980
|
if (!challenge) return false;
|
|
792
981
|
if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
|
|
793
982
|
if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
|
|
794
|
-
|
|
983
|
+
if (challenge.path && challenge.path !== path) return false;
|
|
984
|
+
return await verifySolution(solution.nonce, solution.value, challenge.difficulty);
|
|
795
985
|
} catch (e) {
|
|
796
986
|
auth.logger.error("[sentinel] Failed to verify challenge", e);
|
|
797
987
|
return false;
|
|
798
988
|
}
|
|
799
989
|
})();
|
|
800
|
-
|
|
801
|
-
return
|
|
990
|
+
acceptedSolutions.set(key, accepted);
|
|
991
|
+
return accepted;
|
|
802
992
|
}
|
|
803
993
|
/** Runs a check, applying `failOpen` when it can't complete */
|
|
804
994
|
async function guard(auth, check) {
|
|
@@ -845,12 +1035,12 @@ const sentinel = (options) => {
|
|
|
845
1035
|
visitorId: details.visitorId,
|
|
846
1036
|
identifier: details.identifier,
|
|
847
1037
|
userId,
|
|
848
|
-
challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
|
|
1038
|
+
challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path) : false,
|
|
849
1039
|
difficulty,
|
|
850
1040
|
metadata
|
|
851
1041
|
}));
|
|
852
1042
|
}
|
|
853
|
-
/** The key a request's
|
|
1043
|
+
/** The key a request's accepted solution is cached under */
|
|
854
1044
|
function challengeKey(ctx, details) {
|
|
855
1045
|
return ctx.request ?? details.headers ?? ctx;
|
|
856
1046
|
}
|
|
@@ -908,6 +1098,31 @@ const sentinel = (options) => {
|
|
|
908
1098
|
if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
|
|
909
1099
|
return (await intel.lookup(auth, details)).geo;
|
|
910
1100
|
}
|
|
1101
|
+
/** Deletes a user's sign-ins beyond the most recent ones Sentinel keeps */
|
|
1102
|
+
async function deleteOlderSignIns(auth, userId) {
|
|
1103
|
+
const olderSignIns = await auth.adapter.findMany({
|
|
1104
|
+
model: "sentinelSignIn",
|
|
1105
|
+
where: [{
|
|
1106
|
+
field: "userId",
|
|
1107
|
+
value: userId
|
|
1108
|
+
}],
|
|
1109
|
+
sortBy: {
|
|
1110
|
+
field: "createdAt",
|
|
1111
|
+
direction: "desc"
|
|
1112
|
+
},
|
|
1113
|
+
offset: SIGN_IN_HISTORY_LIMIT,
|
|
1114
|
+
limit: 100
|
|
1115
|
+
});
|
|
1116
|
+
if (!olderSignIns.length) return;
|
|
1117
|
+
await auth.adapter.deleteMany({
|
|
1118
|
+
model: "sentinelSignIn",
|
|
1119
|
+
where: [{
|
|
1120
|
+
field: "id",
|
|
1121
|
+
operator: "in",
|
|
1122
|
+
value: olderSignIns.map((signIn) => signIn.id)
|
|
1123
|
+
}]
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
911
1126
|
async function lastSignIn(auth, userId) {
|
|
912
1127
|
const [row] = await auth.adapter.findMany({
|
|
913
1128
|
model: "sentinelSignIn",
|
|
@@ -1031,6 +1246,20 @@ const sentinel = (options) => {
|
|
|
1031
1246
|
});
|
|
1032
1247
|
}));
|
|
1033
1248
|
}
|
|
1249
|
+
if (freeTrialAbuse) {
|
|
1250
|
+
if (details.ip) {
|
|
1251
|
+
const ip = details.ip;
|
|
1252
|
+
throwForDecision(await guard(ctx.context, async () => {
|
|
1253
|
+
const created = await store.get(`trial:ip:${ip}`);
|
|
1254
|
+
if (created < freeTrialAbuse.maxAccountsPerIp) return { outcome: "allow" };
|
|
1255
|
+
return act(event, store, details, freeTrialAbuse.action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", {
|
|
1256
|
+
accounts: created,
|
|
1257
|
+
signal: "ip"
|
|
1258
|
+
});
|
|
1259
|
+
}));
|
|
1260
|
+
}
|
|
1261
|
+
if (freeTrialAbuse.requireVerifiedVisitor && !details.visitorVerified) throwForDecision(await act(event, store, details, "challenge", SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "visitor_not_verified", {}));
|
|
1262
|
+
}
|
|
1034
1263
|
if (freeTrialAbuse && details.visitorId) {
|
|
1035
1264
|
const visitorId = details.visitorId;
|
|
1036
1265
|
throwForDecision(await guard(ctx.context, async () => {
|
|
@@ -1068,9 +1297,10 @@ const sentinel = (options) => {
|
|
|
1068
1297
|
const headers = event.headers ?? event.request?.headers;
|
|
1069
1298
|
const ip = clientIp(event.request ?? headers, context.context);
|
|
1070
1299
|
const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
|
|
1071
|
-
if (!visitorId) return;
|
|
1072
1300
|
try {
|
|
1073
|
-
|
|
1301
|
+
const store = createSentinelStore(context.context);
|
|
1302
|
+
if (visitorId) await store.increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
|
|
1303
|
+
if (ip) await store.increment(`trial:ip:${ip}`, freeTrialAbuse.ipWindowSeconds);
|
|
1074
1304
|
} catch (e) {
|
|
1075
1305
|
context.context.logger.error("[sentinel] Failed to track sign-up", e);
|
|
1076
1306
|
}
|
|
@@ -1087,34 +1317,37 @@ const sentinel = (options) => {
|
|
|
1087
1317
|
},
|
|
1088
1318
|
async onRequest(request, ctx) {
|
|
1089
1319
|
if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
|
|
1320
|
+
const path = relativePath(request, ctx);
|
|
1321
|
+
const checkBot = Boolean(botBlocking) && BOT_CHECK_PATHS.includes(path);
|
|
1322
|
+
if (!checkBot && !suspiciousIpBlocking && !geoBlocking) return;
|
|
1090
1323
|
const store = createSentinelStore(ctx);
|
|
1091
1324
|
const event = {
|
|
1092
1325
|
context: ctx,
|
|
1093
1326
|
request,
|
|
1094
1327
|
headers: request.headers,
|
|
1095
|
-
path
|
|
1328
|
+
path
|
|
1096
1329
|
};
|
|
1097
1330
|
const response = responseForDecision(await guard(ctx, async () => {
|
|
1098
1331
|
const details = await requestDetails(event);
|
|
1099
1332
|
const { ip } = details;
|
|
1100
|
-
const
|
|
1333
|
+
const backendVerdicts = await intel.lookup(ctx, details);
|
|
1101
1334
|
if (suspiciousIpBlocking && ip) {
|
|
1102
|
-
if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) :
|
|
1103
|
-
const
|
|
1104
|
-
if (
|
|
1335
|
+
if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : backendVerdicts.suspiciousIp) {
|
|
1336
|
+
const suspiciousIpDecision = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
|
|
1337
|
+
if (suspiciousIpDecision.outcome !== "allow") return suspiciousIpDecision;
|
|
1105
1338
|
}
|
|
1106
1339
|
}
|
|
1107
1340
|
if (geoBlocking && ip) {
|
|
1108
|
-
const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) :
|
|
1341
|
+
const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : backendVerdicts.geo)?.country?.toUpperCase() ?? null;
|
|
1109
1342
|
if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
|
|
1110
|
-
const
|
|
1111
|
-
if (
|
|
1343
|
+
const geoDecision = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
|
|
1344
|
+
if (geoDecision.outcome !== "allow") return geoDecision;
|
|
1112
1345
|
}
|
|
1113
1346
|
}
|
|
1114
|
-
if (botBlocking) {
|
|
1115
|
-
if (opts.providers?.isBot ? await opts.providers.isBot(request) :
|
|
1116
|
-
const
|
|
1117
|
-
if (
|
|
1347
|
+
if (botBlocking && checkBot) {
|
|
1348
|
+
if (opts.providers?.isBot ? await opts.providers.isBot(request) : backendVerdicts.bot ?? looksLikeBot(request)) {
|
|
1349
|
+
const botDecision = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
|
|
1350
|
+
if (botDecision.outcome !== "allow") return botDecision;
|
|
1118
1351
|
}
|
|
1119
1352
|
}
|
|
1120
1353
|
return { outcome: "allow" };
|
|
@@ -1160,7 +1393,7 @@ const sentinel = (options) => {
|
|
|
1160
1393
|
}
|
|
1161
1394
|
if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
|
|
1162
1395
|
const keys = attemptKeys(details);
|
|
1163
|
-
const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
|
|
1396
|
+
const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path);
|
|
1164
1397
|
const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
|
|
1165
1398
|
const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
|
|
1166
1399
|
const { thresholds } = credentialStuffing;
|
|
@@ -1189,12 +1422,12 @@ const sentinel = (options) => {
|
|
|
1189
1422
|
if (limit) {
|
|
1190
1423
|
const count = await store.increment(limit.name, windowSeconds);
|
|
1191
1424
|
if (count > limit.max) {
|
|
1192
|
-
const
|
|
1425
|
+
const limitDecision = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
|
|
1193
1426
|
count,
|
|
1194
1427
|
limit: limit.max,
|
|
1195
1428
|
windowSeconds
|
|
1196
1429
|
});
|
|
1197
|
-
if (
|
|
1430
|
+
if (limitDecision.outcome !== "allow") return limitDecision;
|
|
1198
1431
|
}
|
|
1199
1432
|
}
|
|
1200
1433
|
if (ids.length && isTracked) {
|
|
@@ -1371,6 +1604,7 @@ const sentinel = (options) => {
|
|
|
1371
1604
|
createdAt: /* @__PURE__ */ new Date()
|
|
1372
1605
|
}
|
|
1373
1606
|
});
|
|
1607
|
+
await deleteOlderSignIns(auth, user.id);
|
|
1374
1608
|
} catch (e) {
|
|
1375
1609
|
if (e instanceof APIError) throw e;
|
|
1376
1610
|
auth.logger.error("[sentinel] Failed to track sign-in", e);
|