@proteinjs/user-server 1.22.0 → 1.22.2
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/CHANGELOG.md +24 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +9 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
- package/dist/src/authentication/PasswordHasher.js +25 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -1
- package/dist/src/authentication/PasswordResetToken.d.ts +43 -12
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
- package/dist/src/authentication/PasswordResetToken.js +86 -21
- package/dist/src/authentication/PasswordResetToken.js.map +1 -1
- package/dist/src/authentication/authenticate.d.ts +15 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +55 -20
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +6 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +16 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +55 -34
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts +8 -0
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +63 -22
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/throttle/ClientAddress.d.ts +35 -0
- package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
- package/dist/src/throttle/ClientAddress.js +48 -0
- package/dist/src/throttle/ClientAddress.js.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.js +59 -0
- package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
- package/dist/src/throttle/RequestDigests.d.ts +42 -0
- package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
- package/dist/src/throttle/RequestDigests.js +96 -0
- package/dist/src/throttle/RequestDigests.js.map +1 -0
- package/dist/src/throttle/SignInThrottle.d.ts +67 -0
- package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
- package/dist/src/throttle/SignInThrottle.js +91 -0
- package/dist/src/throttle/SignInThrottle.js.map +1 -0
- package/dist/src/throttle/SlidingWindow.d.ts +51 -0
- package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
- package/dist/src/throttle/SlidingWindow.js +85 -0
- package/dist/src/throttle/SlidingWindow.js.map +1 -0
- package/dist/test/ClientAddress.test.d.ts +2 -0
- package/dist/test/ClientAddress.test.d.ts.map +1 -0
- package/dist/test/ClientAddress.test.js +51 -0
- package/dist/test/ClientAddress.test.js.map +1 -0
- package/dist/test/DbTraffic.d.ts +29 -0
- package/dist/test/DbTraffic.d.ts.map +1 -0
- package/dist/test/DbTraffic.js +106 -0
- package/dist/test/DbTraffic.js.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
- package/dist/test/ExecutePasswordReset.test.js +513 -124
- package/dist/test/ExecutePasswordReset.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
- package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
- package/dist/test/InitiatePasswordReset.test.js +783 -0
- package/dist/test/InitiatePasswordReset.test.js.map +1 -0
- package/dist/test/LogCapture.d.ts +22 -0
- package/dist/test/LogCapture.d.ts.map +1 -0
- package/dist/test/LogCapture.js +127 -0
- package/dist/test/LogCapture.js.map +1 -0
- package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
- package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
- package/dist/test/LoginThrottle.integration.test.js +616 -0
- package/dist/test/LoginThrottle.integration.test.js.map +1 -0
- package/dist/test/MalformedResetTokens.d.ts +11 -0
- package/dist/test/MalformedResetTokens.d.ts.map +1 -0
- package/dist/test/MalformedResetTokens.js +35 -0
- package/dist/test/MalformedResetTokens.js.map +1 -0
- package/dist/test/RequestDigests.test.d.ts +2 -0
- package/dist/test/RequestDigests.test.d.ts.map +1 -0
- package/dist/test/RequestDigests.test.js +73 -0
- package/dist/test/RequestDigests.test.js.map +1 -0
- package/dist/test/SlidingWindow.test.d.ts +2 -0
- package/dist/test/SlidingWindow.test.d.ts.map +1 -0
- package/dist/test/SlidingWindow.test.js +72 -0
- package/dist/test/SlidingWindow.test.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +156 -33
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/dist/test/emulatorLock.test.d.ts +2 -0
- package/dist/test/emulatorLock.test.d.ts.map +1 -0
- package/dist/test/emulatorLock.test.js +32 -0
- package/dist/test/emulatorLock.test.js.map +1 -0
- package/dist/test/emulatorLockName.test.d.ts +2 -0
- package/dist/test/emulatorLockName.test.d.ts.map +1 -0
- package/dist/test/emulatorLockName.test.js +36 -0
- package/dist/test/emulatorLockName.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +6 -1
- package/jest.config.js +2 -2
- package/package.json +3 -3
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/authentication/authenticate.ts +36 -9
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/executePasswordReset.ts +10 -2
- package/src/routes/initiatePasswordReset.ts +46 -25
- package/src/routes/login.ts +39 -8
- package/src/throttle/ClientAddress.ts +47 -0
- package/src/throttle/PasswordResetThrottle.ts +62 -0
- package/src/throttle/RequestDigests.ts +97 -0
- package/src/throttle/SignInThrottle.ts +98 -0
- package/src/throttle/SlidingWindow.ts +90 -0
- package/test/ClientAddress.test.ts +54 -0
- package/test/DbTraffic.ts +43 -0
- package/test/DevLoginBootstrapAdmin.test.ts +3 -4
- package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
- package/test/ExecutePasswordReset.test.ts +308 -70
- package/test/InitiatePasswordReset.test.ts +456 -0
- package/test/LogCapture.ts +54 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MalformedResetTokens.ts +27 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SlidingWindow.test.ts +85 -0
- package/test/ValidateResetToken.test.ts +108 -24
- package/test/emulatorLock.js +5 -6
- package/test/emulatorLock.test.ts +32 -0
- package/test/emulatorLockName.test.ts +33 -0
- package/test/setup.js +2 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHmac } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two keyed digests the throttled doors key their windows on and write on their log lines,
|
|
5
|
+
* in place of what they stand for:
|
|
6
|
+
* - `account(email)` — the ACCOUNT DIGEST: a keyed hash of the trimmed, lowercased address.
|
|
7
|
+
* One address gives one digest however it was typed, on every replica and across restarts, so
|
|
8
|
+
* an operator can say "one account" across lines without the address.
|
|
9
|
+
* - `coarseIp(address)` — the COARSE IP HASH: a keyed hash of the client address at the grain
|
|
10
|
+
* one device holds (an IPv4 address; an IPv6 /64, inside which a device rotates freely), so
|
|
11
|
+
* an operator can say "one device" and a throttle cannot be dodged by rotating inside a /64.
|
|
12
|
+
*
|
|
13
|
+
* Keyed with HMAC-SHA256 under a key DERIVED from the session secret — the one secret every
|
|
14
|
+
* replica already shares, the same keying the invite-request door's IP hash uses; derived per
|
|
15
|
+
* purpose, never used raw, so neither digest weakens the session's own use of it, and the two
|
|
16
|
+
* digests never collide in meaning. Truncated to 64 bits: enough to tell accounts and devices
|
|
17
|
+
* apart, useless to anyone without the key — never the address, never a plain hash a list of
|
|
18
|
+
* addresses could reverse.
|
|
19
|
+
*
|
|
20
|
+
* The key is the deployment's `SESSION_SECRET` unless the constructor is given one (the tests).
|
|
21
|
+
* Without either the digests refuse to run: a server with no `SESSION_SECRET` has no sessions
|
|
22
|
+
* either (the session middleware refuses to start), so nothing ever runs unkeyed — never a plain
|
|
23
|
+
* hash a list of addresses could reverse, never a per-process key that quietly stops matching
|
|
24
|
+
* across replicas.
|
|
25
|
+
*/
|
|
26
|
+
export class RequestDigests {
|
|
27
|
+
/** Hex characters kept from the HMAC: 64 bits. */
|
|
28
|
+
private static readonly DIGEST_HEX_LENGTH = 16;
|
|
29
|
+
|
|
30
|
+
constructor(private readonly options?: { secret?: string }) {}
|
|
31
|
+
|
|
32
|
+
/** The account digest of an address, however it was typed. */
|
|
33
|
+
account(email: string): string {
|
|
34
|
+
return this.digest('account-digest', email.trim().toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The coarse IP hash of a client address (see `ClientAddress`). */
|
|
38
|
+
coarseIp(address: string): string {
|
|
39
|
+
return this.digest('coarse-ip', this.coarsen(address.trim().toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private digest(purpose: string, value: string): string {
|
|
43
|
+
return createHmac('sha256', `${purpose}:${this.secret()}`)
|
|
44
|
+
.update(value)
|
|
45
|
+
.digest('hex')
|
|
46
|
+
.slice(0, RequestDigests.DIGEST_HEX_LENGTH);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private secret(): string {
|
|
50
|
+
const configured = this.options?.secret ?? process.env.SESSION_SECRET;
|
|
51
|
+
if (!configured) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'SESSION_SECRET is not set: the account digest and the coarse IP hash need the key every replica shares'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return configured;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An IPv4 address as itself (an IPv4-mapped IPv6 address as its IPv4); an IPv6 address as its /64. */
|
|
60
|
+
private coarsen(address: string): string {
|
|
61
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address);
|
|
62
|
+
if (mapped) {
|
|
63
|
+
return mapped[1];
|
|
64
|
+
}
|
|
65
|
+
if (!address.includes(':')) {
|
|
66
|
+
return address;
|
|
67
|
+
}
|
|
68
|
+
const groups = this.ipv6Groups(address.split('%')[0]);
|
|
69
|
+
return groups ? `${groups.slice(0, 4).join(':')}::/64` : address;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The first four groups (the /64) of an IPv6 address, each without leading zeros; undefined when it does not parse. */
|
|
73
|
+
private ipv6Groups(address: string): string[] | undefined {
|
|
74
|
+
const halves = address.split('::');
|
|
75
|
+
if (halves.length > 2) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const parts = (half: string | undefined) => (half ? half.split(':') : []);
|
|
79
|
+
const head = parts(halves[0]);
|
|
80
|
+
const tail = parts(halves[1]);
|
|
81
|
+
// A trailing dotted IPv4 part stands for two groups.
|
|
82
|
+
const groupsIn = (list: string[]) => list.reduce((count, part) => count + (part.includes('.') ? 2 : 1), 0);
|
|
83
|
+
const missing = 8 - groupsIn(head) - groupsIn(tail);
|
|
84
|
+
if (missing < 0 || (halves.length === 1 && missing !== 0)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const zeros: string[] = [];
|
|
88
|
+
for (let i = 0; i < missing; i++) {
|
|
89
|
+
zeros.push('0');
|
|
90
|
+
}
|
|
91
|
+
const groups = head.concat(zeros, tail).slice(0, 4);
|
|
92
|
+
if (groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return groups.map((group) => parseInt(group, 16).toString(16));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { SlidingWindow } from './SlidingWindow';
|
|
2
|
+
|
|
3
|
+
/** Which window refused a try: the client's (its coarse IP hash) or the account's (its digest). */
|
|
4
|
+
export type ThrottleWindow = 'client' | 'account';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sign-in door's two windows (`POST /user/login`), keyed by digests (`RequestDigests`),
|
|
8
|
+
* never by an address:
|
|
9
|
+
* - per CLIENT: every try that is not a success counts — wrong passwords, blank ones too, the
|
|
10
|
+
* client made them — so one device cannot sweep many accounts; a successful sign-in never
|
|
11
|
+
* counts (it is forgiven the moment it succeeds), so the people behind one shared address
|
|
12
|
+
* signing in do not spend the window on each other;
|
|
13
|
+
* - per ACCOUNT: every try that carries a password counts (a blank submission judges no
|
|
14
|
+
* password, so it never counts), so guesses spread over many devices still stop; a success
|
|
15
|
+
* clears the account's count. A try is counted as it ARRIVES, before it is judged: tries in
|
|
16
|
+
* flight at the same moment cannot all pass the window together.
|
|
17
|
+
*
|
|
18
|
+
* A throttled try is told `ANSWER` whichever window refused it and whether or not the address
|
|
19
|
+
* has an account, in the same time a refused password takes (the door runs the same password
|
|
20
|
+
* check and discards its verdict).
|
|
21
|
+
*
|
|
22
|
+
* The windows are in process memory (`SlidingWindow`): per replica. A deployment of three
|
|
23
|
+
* replicas behind a load balancer (up to ten under load) keeps three sets of windows, so its
|
|
24
|
+
* effective ceiling is ~3× the numbers below (up to ~10×). Friction, not the wall — a shared
|
|
25
|
+
* store is a separate step.
|
|
26
|
+
*/
|
|
27
|
+
export class SignInThrottle {
|
|
28
|
+
/** What a throttled try is told — in plain words, the same for every address, known or not. */
|
|
29
|
+
static readonly ANSWER = 'Too many attempts. Try again in a few minutes.';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per client: 50 tries that are not successes in 10 minutes (the founder's number, 2026-09-23).
|
|
33
|
+
* Successes never count, so an office or a school behind one address signing in at nine o'clock
|
|
34
|
+
* never spends this window on itself — only its mistypes do, and fifty wrong or blank tries in
|
|
35
|
+
* ten minutes is beyond any human population behind one address; a guessing script from one
|
|
36
|
+
* device is held to ~300 tries an hour per replica.
|
|
37
|
+
*/
|
|
38
|
+
private static readonly CLIENT_LIMIT = 50;
|
|
39
|
+
private static readonly CLIENT_WINDOW_MS = 10 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per account: 10 tries in 15 minutes (a success clears them, so only wrong ones ever add up).
|
|
43
|
+
* Someone who has forgotten a password tries a handful and asks for a reset link; ten wrong in
|
|
44
|
+
* a quarter of an hour is guessing, and counting per account holds however many devices the
|
|
45
|
+
* guesses come from. "A few minutes" in the answer is honest: the oldest try leaves the window
|
|
46
|
+
* within 15.
|
|
47
|
+
*/
|
|
48
|
+
private static readonly ACCOUNT_LIMIT = 10;
|
|
49
|
+
private static readonly ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
private readonly clients: SlidingWindow;
|
|
52
|
+
private readonly accounts: SlidingWindow;
|
|
53
|
+
|
|
54
|
+
constructor(options?: { now?: () => number }) {
|
|
55
|
+
this.clients = new SlidingWindow({
|
|
56
|
+
windowMs: SignInThrottle.CLIENT_WINDOW_MS,
|
|
57
|
+
limit: SignInThrottle.CLIENT_LIMIT,
|
|
58
|
+
now: options?.now,
|
|
59
|
+
});
|
|
60
|
+
this.accounts = new SlidingWindow({
|
|
61
|
+
windowMs: SignInThrottle.ACCOUNT_WINDOW_MS,
|
|
62
|
+
limit: SignInThrottle.ACCOUNT_LIMIT,
|
|
63
|
+
now: options?.now,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Count this try against the client and against the account, and answer which window refuses
|
|
69
|
+
* it, if any. `account` is the digest of the address tried, given only when a password came
|
|
70
|
+
* with it (a blank submission judges no password, so it never counts toward an account). The
|
|
71
|
+
* count is taken here, before the try is judged, so tries in flight at once cannot all pass
|
|
72
|
+
* the window; a success clears it (`recordSuccess`).
|
|
73
|
+
*/
|
|
74
|
+
admit(client: string, account?: string): ThrottleWindow | undefined {
|
|
75
|
+
if (this.clients.hit(client)) {
|
|
76
|
+
return 'client';
|
|
77
|
+
}
|
|
78
|
+
if (account !== undefined && this.accounts.hit(account)) {
|
|
79
|
+
return 'account';
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The account proved itself: its window opens again. When it was a sign-in try from `client`
|
|
86
|
+
* (rather than a reset link redeemed), that try is forgiven — a success never counts against
|
|
87
|
+
* the device.
|
|
88
|
+
*/
|
|
89
|
+
recordSuccess(account: string, client?: string): void {
|
|
90
|
+
this.accounts.clear(account);
|
|
91
|
+
if (client !== undefined) {
|
|
92
|
+
this.clients.forgive(client);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The process-wide windows the sign-in door shares — they must span requests. */
|
|
98
|
+
export const signInThrottle = new SignInThrottle();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-key sliding window: how many attempts a key made in the last `windowMs`, against a
|
|
3
|
+
* `limit`. The one window every throttled door shares — the sign-in and password-reset doors in
|
|
4
|
+
* this package, and any consumer door throttling per client (lifted from the invite-request
|
|
5
|
+
* door's per-IP throttle, which it replaces).
|
|
6
|
+
*
|
|
7
|
+
* Held in process memory only, which is what makes it cheap and what bounds it:
|
|
8
|
+
* - PER PROCESS. Each replica keeps its own windows, so behind a load balancer spreading a
|
|
9
|
+
* client over N replicas the effective ceiling is ~N× the limit; a restart forgets them.
|
|
10
|
+
* A throttle built on this is friction, not the wall. A shared store is a separate step.
|
|
11
|
+
* - BOUNDED. At most `maxKeys` keys are tracked; at the bound the oldest-touched key is
|
|
12
|
+
* forgotten first, so a flood of fresh keys degrades the friction before it grows memory.
|
|
13
|
+
*
|
|
14
|
+
* Keys are whatever the caller passes — callers pass digests (see `RequestDigests`), never raw
|
|
15
|
+
* addresses, so nothing identifying is held here either.
|
|
16
|
+
*/
|
|
17
|
+
export class SlidingWindow {
|
|
18
|
+
private static readonly DEFAULT_MAX_KEYS = 10_000;
|
|
19
|
+
|
|
20
|
+
private readonly windowMs: number;
|
|
21
|
+
private readonly limit: number;
|
|
22
|
+
private readonly maxKeys: number;
|
|
23
|
+
private readonly now: () => number;
|
|
24
|
+
/** key → the counted attempt times inside the window (pruned on touch), in touch order. */
|
|
25
|
+
private readonly attempts = new Map<string, number[]>();
|
|
26
|
+
|
|
27
|
+
constructor(options: { windowMs: number; limit: number; maxKeys?: number; now?: () => number }) {
|
|
28
|
+
this.windowMs = options.windowMs;
|
|
29
|
+
this.limit = options.limit;
|
|
30
|
+
this.maxKeys = options.maxKeys ?? SlidingWindow.DEFAULT_MAX_KEYS;
|
|
31
|
+
this.now = options.now ?? Date.now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Record an attempt for `key` and answer whether it was over the window — `true` means refuse.
|
|
36
|
+
* A refused attempt is not counted, so a key drains one window after its last counted attempt.
|
|
37
|
+
*/
|
|
38
|
+
hit(key: string): boolean {
|
|
39
|
+
if (this.isOver(key)) {
|
|
40
|
+
this.touch(key, this.inWindow(key));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
this.record(key);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether `key` has used up its window, without counting anything. */
|
|
48
|
+
isOver(key: string): boolean {
|
|
49
|
+
return this.inWindow(key).length >= this.limit;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Count an attempt for `key` without answering (for doors that judge the attempt first). */
|
|
53
|
+
record(key: string): void {
|
|
54
|
+
const attempts = this.inWindow(key);
|
|
55
|
+
attempts.push(this.now());
|
|
56
|
+
this.touch(key, attempts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Forget `key`'s attempts. */
|
|
60
|
+
clear(key: string): void {
|
|
61
|
+
this.attempts.delete(key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Uncount `key`'s latest attempt (for doors that count a try as it arrives and learn afterwards
|
|
66
|
+
* that it should never have counted — a sign-in that succeeded).
|
|
67
|
+
*/
|
|
68
|
+
forgive(key: string): void {
|
|
69
|
+
const attempts = this.inWindow(key);
|
|
70
|
+
attempts.pop();
|
|
71
|
+
this.touch(key, attempts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private inWindow(key: string): number[] {
|
|
75
|
+
const cutoff = this.now() - this.windowMs;
|
|
76
|
+
return (this.attempts.get(key) ?? []).filter((time) => time > cutoff);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Store `key`'s attempts as the most recently touched, evicting the oldest-touched key at the bound. */
|
|
80
|
+
private touch(key: string, attempts: number[]): void {
|
|
81
|
+
if (!this.attempts.has(key) && this.attempts.size >= this.maxKeys) {
|
|
82
|
+
const oldest = this.attempts.keys().next().value;
|
|
83
|
+
if (oldest !== undefined) {
|
|
84
|
+
this.attempts.delete(oldest);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.attempts.delete(key);
|
|
88
|
+
this.attempts.set(key, attempts);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ClientAddress } from '../src/throttle/ClientAddress';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which address a throttle keys on. Behind Google's external Application Load Balancer the
|
|
5
|
+
* header ends `<client-ip>,<load-balancer-ip>` — the balancer appends both, and anything before
|
|
6
|
+
* them is whatever the client itself sent. So the client is the SECOND entry from the right:
|
|
7
|
+
* never the last (the balancer's own address, the same for every visitor — a throttle keyed on
|
|
8
|
+
* it is one bucket for the whole world) and never the first (client-supplied, rotatable at will).
|
|
9
|
+
*/
|
|
10
|
+
describe('ClientAddress', () => {
|
|
11
|
+
const behindTheBalancer = (forwardedFor: string | undefined, socketAddress = '35.191.0.10') => ({
|
|
12
|
+
app: { get: (setting: string) => (setting === 'trust proxy' ? 1 : undefined) },
|
|
13
|
+
headers: forwardedFor === undefined ? {} : { 'x-forwarded-for': forwardedFor },
|
|
14
|
+
socket: { remoteAddress: socketAddress },
|
|
15
|
+
// What express's own `request.ip` resolves to under `trust proxy 1`: the header's last entry.
|
|
16
|
+
ip: forwardedFor?.split(',').pop()?.trim() ?? socketAddress,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads the client the balancer appended, not the balancer's own address", () => {
|
|
20
|
+
const request = behindTheBalancer('198.51.100.23, 34.120.1.1');
|
|
21
|
+
|
|
22
|
+
expect(new ClientAddress().of(request)).toBe('198.51.100.23');
|
|
23
|
+
expect(request.ip).toBe('34.120.1.1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('ignores whatever the client put in the header itself', () => {
|
|
27
|
+
const spoofed = behindTheBalancer('203.0.113.99, 10.0.0.1, 198.51.100.23, 34.120.1.1');
|
|
28
|
+
|
|
29
|
+
expect(new ClientAddress().of(spoofed)).toBe('198.51.100.23');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reads IPv6 clients the same way', () => {
|
|
33
|
+
expect(new ClientAddress().of(behindTheBalancer('2001:db8:1:2:3:4:5:6,34.120.1.1'))).toBe('2001:db8:1:2:3:4:5:6');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('a request that did not come through the balancer (no appended pair) keys on its own connection', () => {
|
|
37
|
+
expect(new ClientAddress().of(behindTheBalancer(undefined, '10.8.0.7'))).toBe('10.8.0.7');
|
|
38
|
+
expect(new ClientAddress().of(behindTheBalancer('10.8.0.9', '10.8.0.7'))).toBe('10.8.0.7');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('with no proxy trusted (development) the connection is the client, and a forwarded header is ignored', () => {
|
|
42
|
+
const direct = {
|
|
43
|
+
app: { get: () => false },
|
|
44
|
+
headers: { 'x-forwarded-for': '203.0.113.99, 198.51.100.23' },
|
|
45
|
+
socket: { remoteAddress: '::1' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
expect(new ClientAddress().of(direct)).toBe('::1');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a request with no connection details at all reads as the empty address', () => {
|
|
52
|
+
expect(new ClientAddress().of({})).toBe('');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Db, DbDriver } from '@proteinjs/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What reached the database while a run executed — the outcome a "refused before any lookup"
|
|
5
|
+
* claim rests on. A response code cannot prove it: a value that is wrongly let through still
|
|
6
|
+
* matches no row and still answers 400, so the proof is that nothing was built and nothing ran.
|
|
7
|
+
* - `lookups`: the filter of every read built through the package's `Db` — every `query`, which
|
|
8
|
+
* is also what a `get` is (the first row of one);
|
|
9
|
+
* - `writes`: how many writes were built through it (`insert`, `update`, `delete`);
|
|
10
|
+
* - `statements`: how many statements the driver ran, whoever built them.
|
|
11
|
+
*/
|
|
12
|
+
export class DbTraffic {
|
|
13
|
+
private constructor(
|
|
14
|
+
readonly lookups: unknown[],
|
|
15
|
+
readonly writes: number,
|
|
16
|
+
readonly statements: number
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/** The traffic of a run that never touched the database: `expect(traffic).toEqual(DbTraffic.NONE)`. */
|
|
20
|
+
static readonly NONE = { lookups: [], writes: 0, statements: 0 };
|
|
21
|
+
|
|
22
|
+
/** Runs `run` and answers what it resolved to beside the database traffic it caused. */
|
|
23
|
+
static async during<T>(driver: DbDriver, run: () => Promise<T>): Promise<{ result: T; traffic: DbTraffic }> {
|
|
24
|
+
const reads = jest.spyOn(Db.prototype, 'query');
|
|
25
|
+
const writes = [
|
|
26
|
+
jest.spyOn(Db.prototype, 'insert'),
|
|
27
|
+
jest.spyOn(Db.prototype, 'update'),
|
|
28
|
+
jest.spyOn(Db.prototype, 'delete'),
|
|
29
|
+
];
|
|
30
|
+
const statements = [jest.spyOn(driver, 'runQuery'), jest.spyOn(driver, 'runDml')];
|
|
31
|
+
try {
|
|
32
|
+
const result = await run();
|
|
33
|
+
const lookups = reads.mock.calls.map(([_table, filter]) => filter);
|
|
34
|
+
return { result, traffic: new DbTraffic(lookups, DbTraffic.callCount(writes), DbTraffic.callCount(statements)) };
|
|
35
|
+
} finally {
|
|
36
|
+
[reads, ...writes, ...statements].forEach((spy) => spy.mockRestore());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private static callCount(spies: { mock: { calls: unknown[] } }[]): number {
|
|
41
|
+
return spies.reduce((count, spy) => count + spy.mock.calls.length, 0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -6,10 +6,9 @@ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
|
6
6
|
const testEnv = new UserServerTestEnvironment();
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* sanctioned account door mints break-glass, once:
|
|
9
|
+
* `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login`. A development server on
|
|
10
|
+
* a fresh real database has no privileged account to grant from, and a no-raw-database-write rule
|
|
11
|
+
* forbids seed scripts there — so the ONE sanctioned account door mints break-glass, once:
|
|
13
12
|
* - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
|
|
14
13
|
* before, and the variable changes nothing;
|
|
15
14
|
* - only while NO account carries 'admin' (the same membership test the app's admin checks make);
|
|
@@ -28,7 +28,7 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
28
28
|
* cookies the response set, and the follow-up MUST read as authenticated on the FIRST pass.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
const SESSION_COOKIE_NAME = '
|
|
31
|
+
const SESSION_COOKIE_NAME = 'app.sid.9876'; // a consumer's dev cookie-name shape: <app>.sid.${SERVER_PORT}
|
|
32
32
|
const SESSION_SECRET = 'dev-first-hit-test-secret';
|
|
33
33
|
const ENV_EMAIL = 'dev@test.local';
|
|
34
34
|
/** Store commit latency. Generous so a pre-0.6 runtime (write racing the redirect) loses the
|