@miraj181/ipingyou 2.0.13 → 2.1.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/README.md +8 -1
- package/package.json +7 -6
- package/src/cli.js +216 -0
- package/src/lib/ai/groq.js +116 -0
- package/src/lib/ai/safety.js +114 -0
- package/src/lib/allowlist.js +35 -0
- package/src/lib/broker.js +139 -13
- package/src/lib/cleanup.js +14 -6
- package/src/lib/config.js +8 -3
- package/src/lib/path-browser.js +26 -4
- package/src/lib/platform.js +0 -31
- package/src/lib/session-log.js +93 -0
- package/src/modes/ai.js +627 -0
- package/src/modes/client.js +327 -35
- package/src/modes/doctor.js +293 -0
- package/src/modes/host.js +486 -39
- package/src/server.js +226 -14
package/src/server.js
CHANGED
|
@@ -12,23 +12,57 @@
|
|
|
12
12
|
import express from 'express';
|
|
13
13
|
import rateLimit from 'express-rate-limit';
|
|
14
14
|
import helmet from 'helmet';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { cleanupSessionLog, initSessionLog, logSessionEvent } from './lib/session-log.js';
|
|
15
17
|
|
|
16
18
|
const app = express();
|
|
19
|
+
const brokerLogPath = initSessionLog('broker');
|
|
20
|
+
if (brokerLogPath) {
|
|
21
|
+
console.log(` 📜 Broker session log: ${brokerLogPath}`);
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
// ─── Process-Level Error Handling ────────────────────────────
|
|
25
|
+
let shuttingDown = false;
|
|
26
|
+
|
|
27
|
+
function handleShutdown(signal) {
|
|
28
|
+
if (shuttingDown) return;
|
|
29
|
+
shuttingDown = true;
|
|
30
|
+
logSessionEvent('broker_shutdown', { signal });
|
|
31
|
+
cleanupSessionLog();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
36
|
+
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
37
|
+
|
|
19
38
|
process.on('uncaughtException', (err) => {
|
|
20
39
|
console.error('💥 Uncaught Exception:', err.message);
|
|
40
|
+
logSessionEvent('broker_uncaught_exception', { error: err.message }, 'error');
|
|
21
41
|
if (err.stack) console.error(err.stack);
|
|
22
42
|
process.exit(1);
|
|
23
43
|
});
|
|
24
44
|
|
|
25
45
|
process.on('unhandledRejection', (reason, promise) => {
|
|
26
46
|
console.error('💥 Unhandled Rejection at:', promise, 'reason:', reason);
|
|
47
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
48
|
+
logSessionEvent('broker_unhandled_rejection', { error: message }, 'error');
|
|
27
49
|
});
|
|
28
50
|
|
|
29
51
|
// ─── Basic Security Headers ──────────────────────────────────
|
|
30
52
|
app.use(helmet());
|
|
31
53
|
app.use(express.json({ limit: '10kb' })); // Limit JSON payload size to prevent payload-based DoS
|
|
54
|
+
app.use((req, res, next) => {
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
res.on('finish', () => {
|
|
57
|
+
logSessionEvent('broker_http', {
|
|
58
|
+
method: req.method,
|
|
59
|
+
path: req.originalUrl,
|
|
60
|
+
status: res.statusCode,
|
|
61
|
+
durationMs: Date.now() - startedAt,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
next();
|
|
65
|
+
});
|
|
32
66
|
|
|
33
67
|
// ─── Rate Limiters ───────────────────────────────────────────
|
|
34
68
|
// Trust proxy is required if the server is behind a reverse proxy (like Render, Heroku, Cloudflare)
|
|
@@ -83,8 +117,40 @@ function recordViolation(req) {
|
|
|
83
117
|
const TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
84
118
|
const MAX_UIDS = 50000; // Max concurrent tunnels (prevent memory leak)
|
|
85
119
|
const MAX_VIOLATIONS = 50000; // Max tracked malicious IPs before reset
|
|
120
|
+
const MAX_RESOLVES_PER_UID = 100; // Max resolves before auto-revoke (anti-scraping)
|
|
121
|
+
const BROKER_SECRET = process.env.BROKER_HMAC_SECRET || crypto.randomBytes(32).toString('hex');
|
|
122
|
+
|
|
123
|
+
const store = new Map(); // uid → { iv, ciphertext, salt, createdAt, clients: [], approvals: [], hostToken, resolveCount }
|
|
86
124
|
|
|
87
|
-
|
|
125
|
+
// ─── Security Helpers ────────────────────────────────────────
|
|
126
|
+
const SAFE_PARAM = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
127
|
+
|
|
128
|
+
function isSafeParam(val) {
|
|
129
|
+
return typeof val === 'string' && SAFE_PARAM.test(val);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generateHostToken(uid) {
|
|
133
|
+
return crypto.createHmac('sha256', BROKER_SECRET).update(uid).digest('hex');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireHostToken(req, res, entry) {
|
|
137
|
+
const token = req.headers['x-host-token'];
|
|
138
|
+
if (!token || token !== entry.hostToken) {
|
|
139
|
+
recordViolation(req);
|
|
140
|
+
res.status(403).json({ error: 'Forbidden — invalid or missing host authentication token' });
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isEncryptedPayload(body) {
|
|
147
|
+
return body
|
|
148
|
+
&& /^[a-f0-9]{32}$/i.test(body.iv || '')
|
|
149
|
+
&& /^[a-f0-9]{32}$/i.test(body.salt || '')
|
|
150
|
+
&& typeof body.ciphertext === 'string'
|
|
151
|
+
&& body.ciphertext.length > 0
|
|
152
|
+
&& /^[A-Za-z0-9+/]+={0,2}$/.test(body.ciphertext);
|
|
153
|
+
}
|
|
88
154
|
|
|
89
155
|
function pruneExpired() {
|
|
90
156
|
const now = Date.now();
|
|
@@ -119,7 +185,7 @@ app.get('/health', (_req, res) => {
|
|
|
119
185
|
*/
|
|
120
186
|
app.post('/register', strictLimiter, (req, res) => {
|
|
121
187
|
try {
|
|
122
|
-
const { uid, iv, ciphertext, salt } = req.body;
|
|
188
|
+
const { uid, iv, ciphertext, salt, approvalRequired = false, oneTime = false } = req.body;
|
|
123
189
|
|
|
124
190
|
if (!uid || !iv || !ciphertext || !salt) {
|
|
125
191
|
recordViolation(req);
|
|
@@ -153,17 +219,26 @@ app.post('/register', strictLimiter, (req, res) => {
|
|
|
153
219
|
return res.status(503).json({ error: 'Broker is at maximum capacity. Please try again later.' });
|
|
154
220
|
}
|
|
155
221
|
|
|
222
|
+
// Generate a cryptographic host token — only the host receives this
|
|
223
|
+
const hostToken = generateHostToken(uid + Date.now().toString());
|
|
224
|
+
|
|
156
225
|
// Store the encrypted blob as-is — broker NEVER decrypts
|
|
157
226
|
store.set(uid, {
|
|
158
227
|
iv,
|
|
159
228
|
ciphertext,
|
|
160
229
|
salt,
|
|
230
|
+
approvalRequired: Boolean(approvalRequired),
|
|
231
|
+
oneTime: Boolean(oneTime),
|
|
161
232
|
createdAt: Date.now(),
|
|
162
|
-
clients: []
|
|
233
|
+
clients: [],
|
|
234
|
+
approvals: [],
|
|
235
|
+
hostToken,
|
|
236
|
+
resolveCount: 0,
|
|
163
237
|
});
|
|
164
238
|
|
|
165
239
|
console.log(`✅ [${new Date().toLocaleTimeString()}] Registered UID: ${uid} (encrypted, ${ciphertext.length} bytes)`);
|
|
166
|
-
|
|
240
|
+
// Return host token — this is the ONLY time it's sent; host must store it
|
|
241
|
+
res.json({ status: 'registered', uid, hostToken, expiresIn: '1 hour' });
|
|
167
242
|
} catch (err) {
|
|
168
243
|
console.error('❌ Register error:', err.message);
|
|
169
244
|
res.status(500).json({ error: 'Internal server error' });
|
|
@@ -178,10 +253,14 @@ app.post('/register', strictLimiter, (req, res) => {
|
|
|
178
253
|
app.get('/resolve/:uid', (req, res) => {
|
|
179
254
|
try {
|
|
180
255
|
const { uid } = req.params;
|
|
256
|
+
if (!isSafeParam(uid)) {
|
|
257
|
+
recordViolation(req);
|
|
258
|
+
return res.status(400).json({ error: 'Invalid UID format' });
|
|
259
|
+
}
|
|
260
|
+
|
|
181
261
|
const entry = store.get(uid);
|
|
182
262
|
|
|
183
263
|
if (!entry) {
|
|
184
|
-
// Don't strictly record a violation for one typo, but repeated 404s will be caught by rate limiter
|
|
185
264
|
return res.status(404).json({ error: 'UID not found or expired' });
|
|
186
265
|
}
|
|
187
266
|
|
|
@@ -191,16 +270,133 @@ app.get('/resolve/:uid', (req, res) => {
|
|
|
191
270
|
return res.status(410).json({ error: 'UID expired' });
|
|
192
271
|
}
|
|
193
272
|
|
|
194
|
-
|
|
273
|
+
// Enforce one-time sharing: check BEFORE sending response (prevents race condition)
|
|
274
|
+
if (entry.oneTime && entry.resolveCount > 0) {
|
|
275
|
+
store.delete(uid);
|
|
276
|
+
return res.status(410).json({ error: 'One-time session already consumed' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Anti-scraping: cap total resolves per UID
|
|
280
|
+
if (entry.resolveCount >= MAX_RESOLVES_PER_UID) {
|
|
281
|
+
store.delete(uid);
|
|
282
|
+
return res.status(429).json({ error: 'Resolve limit exceeded — session revoked for security' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (entry.approvalRequired) {
|
|
286
|
+
const requestId = req.query.requestId;
|
|
287
|
+
if (requestId && !isSafeParam(requestId)) {
|
|
288
|
+
recordViolation(req);
|
|
289
|
+
return res.status(400).json({ error: 'Invalid requestId format' });
|
|
290
|
+
}
|
|
291
|
+
const approved = entry.approvals.find(request => request.id === requestId && request.status === 'approved');
|
|
292
|
+
if (!approved) {
|
|
293
|
+
return res.status(423).json({ error: 'Host approval required before resolving this session' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Increment resolve counter atomically BEFORE sending response
|
|
298
|
+
entry.resolveCount = (entry.resolveCount || 0) + 1;
|
|
299
|
+
|
|
300
|
+
console.log(`🔍 [${new Date().toLocaleTimeString()}] Resolved UID: ${uid} (resolve #${entry.resolveCount})`);
|
|
195
301
|
|
|
196
302
|
// Return encrypted blob — client decrypts
|
|
197
303
|
res.json({ uid, iv: entry.iv, ciphertext: entry.ciphertext, salt: entry.salt });
|
|
304
|
+
|
|
305
|
+
// Enforce one-time sharing: auto-delete AFTER response sent
|
|
306
|
+
if (entry.oneTime) {
|
|
307
|
+
store.delete(uid);
|
|
308
|
+
console.log(`🔒 [${new Date().toLocaleTimeString()}] One-time UID ${uid} auto-revoked after first resolve`);
|
|
309
|
+
}
|
|
198
310
|
} catch (err) {
|
|
199
311
|
console.error('❌ Resolve error:', err.message);
|
|
200
312
|
res.status(500).json({ error: 'Internal server error' });
|
|
201
313
|
}
|
|
202
314
|
});
|
|
203
315
|
|
|
316
|
+
/**
|
|
317
|
+
* POST /approval-request/:uid
|
|
318
|
+
* Client submits encrypted approval metadata. Broker cannot decrypt it.
|
|
319
|
+
*/
|
|
320
|
+
app.post('/approval-request/:uid', generalLimiter, (req, res) => {
|
|
321
|
+
try {
|
|
322
|
+
const { uid } = req.params;
|
|
323
|
+
if (!isSafeParam(uid)) {
|
|
324
|
+
recordViolation(req);
|
|
325
|
+
return res.status(400).json({ error: 'Invalid UID format' });
|
|
326
|
+
}
|
|
327
|
+
const entry = store.get(uid);
|
|
328
|
+
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
329
|
+
if (!isEncryptedPayload(req.body)) {
|
|
330
|
+
recordViolation(req);
|
|
331
|
+
return res.status(400).json({ error: 'Invalid encrypted approval payload' });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Generate cryptographically secure request ID
|
|
335
|
+
const id = crypto.randomBytes(12).toString('hex');
|
|
336
|
+
const request = {
|
|
337
|
+
id,
|
|
338
|
+
iv: req.body.iv,
|
|
339
|
+
ciphertext: req.body.ciphertext,
|
|
340
|
+
salt: req.body.salt,
|
|
341
|
+
status: entry.approvalRequired ? 'pending' : 'approved',
|
|
342
|
+
createdAt: Date.now(),
|
|
343
|
+
decidedAt: entry.approvalRequired ? null : Date.now(),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
entry.approvals.push(request);
|
|
347
|
+
if (entry.approvals.length > 50) entry.approvals.shift();
|
|
348
|
+
res.json({ requestId: id, status: request.status, approvalRequired: entry.approvalRequired });
|
|
349
|
+
} catch {
|
|
350
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// HOST-ONLY: List approval requests (requires host token)
|
|
355
|
+
app.get('/approval-requests/:uid', generalLimiter, (req, res) => {
|
|
356
|
+
if (!isSafeParam(req.params.uid)) return res.status(400).json({ error: 'Invalid UID' });
|
|
357
|
+
const entry = store.get(req.params.uid);
|
|
358
|
+
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
359
|
+
// Require host token to view approval list
|
|
360
|
+
if (!requireHostToken(req, res, entry)) return;
|
|
361
|
+
// Strip encrypted payloads — host dashboard only needs id/status/timestamps
|
|
362
|
+
const sanitized = entry.approvals.map(a => ({ id: a.id, status: a.status, createdAt: a.createdAt, decidedAt: a.decidedAt }));
|
|
363
|
+
res.json({ approvalRequired: entry.approvalRequired, approvals: sanitized });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// HOST-ONLY: Decide on an approval request (requires host token)
|
|
367
|
+
app.post('/approval-requests/:uid/:requestId/:decision', strictLimiter, (req, res) => {
|
|
368
|
+
if (!isSafeParam(req.params.uid) || !isSafeParam(req.params.requestId)) {
|
|
369
|
+
recordViolation(req);
|
|
370
|
+
return res.status(400).json({ error: 'Invalid parameter format' });
|
|
371
|
+
}
|
|
372
|
+
const entry = store.get(req.params.uid);
|
|
373
|
+
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
374
|
+
// CRITICAL: Only the host can approve/deny requests
|
|
375
|
+
if (!requireHostToken(req, res, entry)) return;
|
|
376
|
+
const request = entry.approvals.find(item => item.id === req.params.requestId);
|
|
377
|
+
if (!request) return res.status(404).json({ error: 'Request not found' });
|
|
378
|
+
if (!['approved', 'denied'].includes(req.params.decision)) return res.status(400).json({ error: 'Invalid decision' });
|
|
379
|
+
// Prevent re-deciding already decided requests
|
|
380
|
+
if (request.status !== 'pending') return res.status(409).json({ error: 'Request already decided' });
|
|
381
|
+
|
|
382
|
+
request.status = req.params.decision;
|
|
383
|
+
request.decidedAt = Date.now();
|
|
384
|
+
res.json({ status: request.status });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// CLIENT: Check own approval status (requires valid requestId — unguessable 24-char hex)
|
|
388
|
+
app.get('/approval-status/:uid/:requestId', generalLimiter, (req, res) => {
|
|
389
|
+
if (!isSafeParam(req.params.uid) || !isSafeParam(req.params.requestId)) {
|
|
390
|
+
return res.status(400).json({ error: 'Invalid parameter format' });
|
|
391
|
+
}
|
|
392
|
+
const entry = store.get(req.params.uid);
|
|
393
|
+
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
394
|
+
const request = entry.approvals.find(item => item.id === req.params.requestId);
|
|
395
|
+
if (!request) return res.status(404).json({ error: 'Request not found' });
|
|
396
|
+
// Only return status — never leak encrypted approval data
|
|
397
|
+
res.json({ status: request.status });
|
|
398
|
+
});
|
|
399
|
+
|
|
204
400
|
/**
|
|
205
401
|
* POST /client-info/:uid
|
|
206
402
|
* Clients securely post their encrypted hardware telemetry.
|
|
@@ -208,13 +404,18 @@ app.get('/resolve/:uid', (req, res) => {
|
|
|
208
404
|
app.post('/client-info/:uid', generalLimiter, (req, res) => {
|
|
209
405
|
try {
|
|
210
406
|
const { uid } = req.params;
|
|
211
|
-
|
|
212
|
-
|
|
407
|
+
if (!isSafeParam(uid)) {
|
|
408
|
+
recordViolation(req);
|
|
409
|
+
return res.status(400).json({ error: 'Invalid UID format' });
|
|
410
|
+
}
|
|
213
411
|
const entry = store.get(uid);
|
|
214
412
|
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
215
|
-
if (!
|
|
413
|
+
if (!isEncryptedPayload(req.body)) {
|
|
414
|
+
recordViolation(req);
|
|
415
|
+
return res.status(400).json({ error: 'Invalid encrypted telemetry payload' });
|
|
416
|
+
}
|
|
216
417
|
|
|
217
|
-
entry.clients.push({ iv, ciphertext, salt, seenAt: Date.now() });
|
|
418
|
+
entry.clients.push({ iv: req.body.iv, ciphertext: req.body.ciphertext, salt: req.body.salt, seenAt: Date.now() });
|
|
218
419
|
|
|
219
420
|
// Keep max 50 recent client pings to prevent memory leaks
|
|
220
421
|
if (entry.clients.length > 50) entry.clients.shift();
|
|
@@ -229,12 +430,14 @@ app.post('/client-info/:uid', generalLimiter, (req, res) => {
|
|
|
229
430
|
* GET /clients/:uid
|
|
230
431
|
* Host retrieves all securely encrypted client telemetry blobs.
|
|
231
432
|
*/
|
|
433
|
+
// HOST-ONLY: View client telemetry (requires host token)
|
|
232
434
|
app.get('/clients/:uid', generalLimiter, (req, res) => {
|
|
233
435
|
try {
|
|
234
436
|
const { uid } = req.params;
|
|
437
|
+
if (!isSafeParam(uid)) return res.status(400).json({ error: 'Invalid UID format' });
|
|
235
438
|
const entry = store.get(uid);
|
|
236
|
-
|
|
237
439
|
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
440
|
+
if (!requireHostToken(req, res, entry)) return;
|
|
238
441
|
|
|
239
442
|
res.json({ clients: entry.clients });
|
|
240
443
|
} catch (err) {
|
|
@@ -246,16 +449,25 @@ app.get('/clients/:uid', generalLimiter, (req, res) => {
|
|
|
246
449
|
* DELETE /revoke/:uid
|
|
247
450
|
* Allows host to explicitly remove their UID before expiry.
|
|
248
451
|
*/
|
|
452
|
+
// HOST-ONLY: Revoke a session (requires host token)
|
|
249
453
|
app.delete('/revoke/:uid', strictLimiter, (req, res) => {
|
|
250
454
|
const { uid } = req.params;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
455
|
+
if (!isSafeParam(uid)) {
|
|
456
|
+
recordViolation(req);
|
|
457
|
+
return res.status(400).json({ error: 'Invalid UID format' });
|
|
458
|
+
}
|
|
459
|
+
const entry = store.get(uid);
|
|
460
|
+
if (!entry) return res.json({ status: 'not_found' });
|
|
461
|
+
if (!requireHostToken(req, res, entry)) return;
|
|
462
|
+
store.delete(uid);
|
|
463
|
+
console.log(`🚫 [${new Date().toLocaleTimeString()}] Revoked UID: ${uid} (authenticated)`);
|
|
464
|
+
res.json({ status: 'revoked' });
|
|
254
465
|
});
|
|
255
466
|
|
|
256
467
|
// ─── Global Error Handler ────────────────────────────────────
|
|
257
468
|
app.use((err, req, res, next) => {
|
|
258
469
|
console.error('❌ Express Error:', err.message);
|
|
470
|
+
logSessionEvent('broker_express_error', { path: req.originalUrl, method: req.method, error: err.message }, 'error');
|
|
259
471
|
if (err.stack) console.error(err.stack);
|
|
260
472
|
res.status(err.status || 500).json({ error: 'Internal Server Error' });
|
|
261
473
|
});
|