@miraj181/ipingyou 2.0.14 → 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/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
- const store = new Map(); // uid { iv, ciphertext, salt, createdAt, clients: [] }
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
- res.json({ status: 'registered', uid, expiresIn: '1 hour' });
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
- console.log(`🔍 [${new Date().toLocaleTimeString()}] Resolved UID: ${uid} (returning encrypted blob)`);
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
- const { iv, ciphertext, salt } = req.body;
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 (!iv || !ciphertext || !salt) return res.status(400).json({ error: 'Missing encrypted telemetry payload' });
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
- const existed = store.delete(uid);
252
- console.log(`🚫 [${new Date().toLocaleTimeString()}] Revoked UID: ${uid} (existed: ${existed})`);
253
- res.json({ status: existed ? 'revoked' : 'not_found' });
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
  });