@livedesk/hub 0.1.34 → 0.1.35
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/package.json
CHANGED
|
@@ -17,10 +17,18 @@ const HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
|
17
17
|
const REPLAY_TTL_MS = 2 * 60 * 1000;
|
|
18
18
|
const MAX_REPLAY_ENTRIES = 8192;
|
|
19
19
|
const DEFAULT_MAX_PENDING_HANDSHAKES = 128;
|
|
20
|
-
|
|
20
|
+
// One Client can open control, frame, input, file, and audio channels.
|
|
21
|
+
// A 200-computer site behind one NAT can therefore legitimately create
|
|
22
|
+
// more than 1,000 authenticated handshakes during a coordinated restart.
|
|
23
|
+
// Keep a generous raw-IP burst ceiling; actual abuse blocking below is
|
|
24
|
+
// failure-scoped by device whenever a stable device id is available.
|
|
25
|
+
const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 2400;
|
|
21
26
|
const DEFAULT_MAX_CONSECUTIVE_FAILURES = 8;
|
|
22
27
|
const DEFAULT_IP_BLOCK_MS = 60_000;
|
|
23
28
|
const MAX_TRACKED_IPS = 4096;
|
|
29
|
+
const MAX_TRACKED_DEVICE_FAILURES = 16384;
|
|
30
|
+
const BLOCKED_IP_RECOVERY_PROBE_MS = 2_000;
|
|
31
|
+
const IP_SCOPED_FAILURES = new Set(['secure-handshake-json-invalid', 'secure-handshake-too-large']);
|
|
24
32
|
|
|
25
33
|
function directError(code) {
|
|
26
34
|
const error = new Error(code);
|
|
@@ -60,6 +68,26 @@ function safeHandshakeError(error) {
|
|
|
60
68
|
const code = clean(error?.code || error?.message, 100).toLowerCase();
|
|
61
69
|
return /^[-a-z0-9]+$/.test(code) ? code : 'secure-handshake-rejected';
|
|
62
70
|
}
|
|
71
|
+
function normalizeIpAddress(value) {
|
|
72
|
+
return clean(value, 100).replace(/^::ffff:/, '').toLowerCase() || 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPrivateNetworkAddress(value) {
|
|
76
|
+
const address = normalizeIpAddress(value);
|
|
77
|
+
if (address === '127.0.0.1' || address === '::1' || address === 'localhost') return true;
|
|
78
|
+
if (address.startsWith('fe80:') || address.startsWith('fc') || address.startsWith('fd')) return true;
|
|
79
|
+
const parts = address.split('.').map(part => Number(part));
|
|
80
|
+
return parts.length === 4
|
|
81
|
+
&& parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
|
|
82
|
+
&& (parts[0] === 10
|
|
83
|
+
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
84
|
+
|| (parts[0] === 192 && parts[1] === 168));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function failureOwnerKey(ip, deviceId) {
|
|
88
|
+
const normalizedDeviceId = clean(deviceId, 128);
|
|
89
|
+
return normalizedDeviceId ? `${normalizeIpAddress(ip)}|${normalizedDeviceId}` : '';
|
|
90
|
+
}
|
|
63
91
|
|
|
64
92
|
export function createSecureDirectAcceptor({
|
|
65
93
|
authority,
|
|
@@ -77,6 +105,7 @@ export function createSecureDirectAcceptor({
|
|
|
77
105
|
}
|
|
78
106
|
const replayCache = new Map();
|
|
79
107
|
const ipStates = new Map();
|
|
108
|
+
const deviceFailureStates = new Map();
|
|
80
109
|
const maxPendingHandshakes = Math.max(1, Number(abuseLimits.maxPendingHandshakes)
|
|
81
110
|
|| DEFAULT_MAX_PENDING_HANDSHAKES);
|
|
82
111
|
const maxConnectionsPerIpPerMinute = Math.max(1, Number(abuseLimits.maxConnectionsPerIpPerMinute)
|
|
@@ -93,10 +122,13 @@ export function createSecureDirectAcceptor({
|
|
|
93
122
|
let capacityRejections = 0;
|
|
94
123
|
let rateLimitRejections = 0;
|
|
95
124
|
let blockedIpRejections = 0;
|
|
125
|
+
let blockedDeviceRejections = 0;
|
|
126
|
+
let compatibilityRejections = 0;
|
|
127
|
+
let recoveryProbeAdmissions = 0;
|
|
96
128
|
let probeClosures = 0;
|
|
97
129
|
|
|
98
130
|
function remoteAddress(rawSocket) {
|
|
99
|
-
return
|
|
131
|
+
return normalizeIpAddress(rawSocket?.remoteAddress);
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
function pruneIpStates(current) {
|
|
@@ -121,6 +153,7 @@ export function createSecureDirectAcceptor({
|
|
|
121
153
|
connectionCount: 0,
|
|
122
154
|
consecutiveFailures: 0,
|
|
123
155
|
blockedUntil: 0,
|
|
156
|
+
nextRecoveryProbeAt: 0,
|
|
124
157
|
lastSeenAt: current
|
|
125
158
|
};
|
|
126
159
|
ipStates.set(ip, state);
|
|
@@ -133,11 +166,51 @@ export function createSecureDirectAcceptor({
|
|
|
133
166
|
return state;
|
|
134
167
|
}
|
|
135
168
|
|
|
169
|
+
function pruneDeviceFailureStates(current) {
|
|
170
|
+
for (const [key, state] of deviceFailureStates) {
|
|
171
|
+
if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
|
|
172
|
+
deviceFailureStates.delete(key);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
while (deviceFailureStates.size > MAX_TRACKED_DEVICE_FAILURES) {
|
|
176
|
+
const oldest = deviceFailureStates.keys().next().value;
|
|
177
|
+
if (!oldest) break;
|
|
178
|
+
deviceFailureStates.delete(oldest);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getDeviceFailureState(ip, deviceId, current) {
|
|
183
|
+
const key = failureOwnerKey(ip, deviceId);
|
|
184
|
+
if (!key) return null;
|
|
185
|
+
pruneDeviceFailureStates(current);
|
|
186
|
+
let state = deviceFailureStates.get(key);
|
|
187
|
+
if (!state) {
|
|
188
|
+
state = { consecutiveFailures: 0, blockedUntil: 0, lastSeenAt: current };
|
|
189
|
+
deviceFailureStates.set(key, state);
|
|
190
|
+
}
|
|
191
|
+
state.lastSeenAt = current;
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function deviceAdmissionError(ip, deviceId, current) {
|
|
196
|
+
const state = getDeviceFailureState(ip, deviceId, current);
|
|
197
|
+
if (state?.blockedUntil > current) {
|
|
198
|
+
blockedDeviceRejections += 1;
|
|
199
|
+
return 'secure-device-temporarily-blocked';
|
|
200
|
+
}
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
|
|
136
204
|
function admissionError(ip, current) {
|
|
137
205
|
const state = getIpState(ip, current);
|
|
138
206
|
if (state.blockedUntil > current) {
|
|
139
|
-
|
|
140
|
-
|
|
207
|
+
if (state.nextRecoveryProbeAt <= current) {
|
|
208
|
+
state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
|
|
209
|
+
recoveryProbeAdmissions += 1;
|
|
210
|
+
} else {
|
|
211
|
+
blockedIpRejections += 1;
|
|
212
|
+
return 'secure-ip-temporarily-blocked';
|
|
213
|
+
}
|
|
141
214
|
}
|
|
142
215
|
if (pendingHandshakes >= maxPendingHandshakes) {
|
|
143
216
|
capacityRejections += 1;
|
|
@@ -151,18 +224,39 @@ export function createSecureDirectAcceptor({
|
|
|
151
224
|
return '';
|
|
152
225
|
}
|
|
153
226
|
|
|
154
|
-
function recordFailure(ip, current) {
|
|
227
|
+
function recordFailure(ip, deviceId, code, current) {
|
|
228
|
+
if (code === 'secure-client-hello-required' && isPrivateNetworkAddress(ip)) {
|
|
229
|
+
compatibilityRejections += 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const deviceState = !IP_SCOPED_FAILURES.has(code)
|
|
233
|
+
? getDeviceFailureState(ip, deviceId, current)
|
|
234
|
+
: null;
|
|
235
|
+
if (deviceState) {
|
|
236
|
+
deviceState.consecutiveFailures += 1;
|
|
237
|
+
if (deviceState.consecutiveFailures >= maxConsecutiveFailures) {
|
|
238
|
+
deviceState.blockedUntil = Math.max(deviceState.blockedUntil, current + ipBlockMs);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
155
242
|
const state = getIpState(ip, current);
|
|
156
243
|
state.consecutiveFailures += 1;
|
|
157
244
|
if (state.consecutiveFailures >= maxConsecutiveFailures) {
|
|
158
245
|
state.blockedUntil = Math.max(state.blockedUntil, current + ipBlockMs);
|
|
246
|
+
state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
|
|
159
247
|
}
|
|
160
248
|
}
|
|
161
249
|
|
|
162
|
-
function recordSuccess(ip, current) {
|
|
250
|
+
function recordSuccess(ip, deviceId, current) {
|
|
163
251
|
const state = getIpState(ip, current);
|
|
164
252
|
state.consecutiveFailures = 0;
|
|
165
253
|
state.blockedUntil = 0;
|
|
254
|
+
state.nextRecoveryProbeAt = 0;
|
|
255
|
+
const deviceState = getDeviceFailureState(ip, deviceId, current);
|
|
256
|
+
if (deviceState) {
|
|
257
|
+
deviceState.consecutiveFailures = 0;
|
|
258
|
+
deviceState.blockedUntil = 0;
|
|
259
|
+
}
|
|
166
260
|
}
|
|
167
261
|
|
|
168
262
|
function rejectSocket(rawSocket, code, ip, observedDeviceId = '') {
|
|
@@ -360,12 +454,12 @@ export function createSecureDirectAcceptor({
|
|
|
360
454
|
settled = true;
|
|
361
455
|
cleanup();
|
|
362
456
|
const code = safeHandshakeError(error);
|
|
363
|
-
if (code === 'secure-handshake-closed' && bytes === 0) {
|
|
457
|
+
if ((code === 'secure-handshake-closed' || code === 'secure-handshake-timeout') && bytes === 0) {
|
|
364
458
|
probeClosures += 1;
|
|
365
459
|
try { rawSocket.destroy(); } catch {}
|
|
366
460
|
return;
|
|
367
461
|
}
|
|
368
|
-
recordFailure(ip, Math.floor(now()));
|
|
462
|
+
recordFailure(ip, observedDeviceId, code, Math.floor(now()));
|
|
369
463
|
rejectSocket(rawSocket, code, ip, observedDeviceId);
|
|
370
464
|
};
|
|
371
465
|
|
|
@@ -390,7 +484,12 @@ export function createSecureDirectAcceptor({
|
|
|
390
484
|
let hello;
|
|
391
485
|
try {
|
|
392
486
|
hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
|
|
393
|
-
observedDeviceId = clean(hello?.deviceId, 128);
|
|
487
|
+
observedDeviceId = clean(hello?.deviceId, 128); const deviceDenied = deviceAdmissionError(ip, observedDeviceId, Math.floor(now()));
|
|
488
|
+
if (deviceDenied) {
|
|
489
|
+
settled = false;
|
|
490
|
+
fail(directError(deviceDenied));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
394
493
|
} catch {
|
|
395
494
|
settled = false;
|
|
396
495
|
fail(directError('secure-handshake-json-invalid'));
|
|
@@ -398,7 +497,7 @@ export function createSecureDirectAcceptor({
|
|
|
398
497
|
}
|
|
399
498
|
try {
|
|
400
499
|
handleHello(rawSocket, hello, combined.subarray(newline + 1));
|
|
401
|
-
recordSuccess(ip, Math.floor(now()));
|
|
500
|
+
recordSuccess(ip, observedDeviceId, Math.floor(now()));
|
|
402
501
|
} catch (error) {
|
|
403
502
|
// settled is reset only for the common failure path so it can emit the
|
|
404
503
|
// bounded rejection response and release the raw socket.
|
|
@@ -424,9 +523,13 @@ export function createSecureDirectAcceptor({
|
|
|
424
523
|
replayRejections,
|
|
425
524
|
pendingHandshakes,
|
|
426
525
|
trackedIpCount: ipStates.size,
|
|
526
|
+
trackedDeviceFailureCount: deviceFailureStates.size,
|
|
427
527
|
capacityRejections,
|
|
428
528
|
rateLimitRejections,
|
|
429
529
|
blockedIpRejections,
|
|
530
|
+
blockedDeviceRejections,
|
|
531
|
+
compatibilityRejections,
|
|
532
|
+
recoveryProbeAdmissions,
|
|
430
533
|
probeClosures,
|
|
431
534
|
limits: {
|
|
432
535
|
maxPendingHandshakes,
|