@xtr-dev/rondevu-server 0.5.6 → 0.5.7

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-server",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "DNS-like WebRTC signaling server with credential-based authentication and service discovery",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
package/src/config.ts CHANGED
@@ -27,6 +27,13 @@ export interface Config {
27
27
  timestampMaxAge: number; // Max age for timestamps (replay protection)
28
28
  timestampMaxFuture: number; // Max future tolerance for timestamps (clock skew)
29
29
  masterEncryptionKey: string; // 64-char hex string for encrypting secrets (32 bytes)
30
+ // Resource limits (for abuse prevention)
31
+ maxOffersPerUser: number; // Max concurrent offers per user
32
+ maxTotalOffers: number; // Max total offers in storage
33
+ maxTotalCredentials: number; // Max total credentials in storage
34
+ maxIceCandidatesPerOffer: number; // Max ICE candidates per offer
35
+ credentialsPerIpPerHour: number; // Rate limit: credentials per IP per hour
36
+ requestsPerIpPerSecond: number; // Rate limit: requests per IP per second
30
37
  }
31
38
 
32
39
  /**
@@ -56,7 +63,7 @@ export function loadConfig(): Config {
56
63
  console.error('⚠️ ONLY use NODE_ENV=development for local development');
57
64
  console.error('⚠️ Generate production key with: openssl rand -hex 32');
58
65
  // Random-looking dev key (not ASCII-readable to prevent accidental production use)
59
- masterEncryptionKey = 'a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2';
66
+ masterEncryptionKey = 'a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0';
60
67
  }
61
68
 
62
69
  // Validate master encryption key format
@@ -101,6 +108,13 @@ export function loadConfig(): Config {
101
108
  timestampMaxAge: parsePositiveInt(process.env.TIMESTAMP_MAX_AGE, '60000', 'TIMESTAMP_MAX_AGE', 1000), // Min 1 second
102
109
  timestampMaxFuture: parsePositiveInt(process.env.TIMESTAMP_MAX_FUTURE, '60000', 'TIMESTAMP_MAX_FUTURE', 1000), // Min 1 second
103
110
  masterEncryptionKey,
111
+ // Resource limits
112
+ maxOffersPerUser: parsePositiveInt(process.env.MAX_OFFERS_PER_USER, '20', 'MAX_OFFERS_PER_USER', 1),
113
+ maxTotalOffers: parsePositiveInt(process.env.MAX_TOTAL_OFFERS, '10000', 'MAX_TOTAL_OFFERS', 1),
114
+ maxTotalCredentials: parsePositiveInt(process.env.MAX_TOTAL_CREDENTIALS, '50000', 'MAX_TOTAL_CREDENTIALS', 1),
115
+ maxIceCandidatesPerOffer: parsePositiveInt(process.env.MAX_ICE_CANDIDATES_PER_OFFER, '50', 'MAX_ICE_CANDIDATES_PER_OFFER', 1),
116
+ credentialsPerIpPerHour: parsePositiveInt(process.env.CREDENTIALS_PER_IP_PER_HOUR, '10', 'CREDENTIALS_PER_IP_PER_HOUR', 1),
117
+ requestsPerIpPerSecond: parsePositiveInt(process.env.REQUESTS_PER_IP_PER_SECOND, '50', 'REQUESTS_PER_IP_PER_SECOND', 1),
104
118
  };
105
119
 
106
120
  return config;
@@ -123,6 +137,13 @@ export const CONFIG_DEFAULTS = {
123
137
  maxTotalOperations: 1000,
124
138
  timestampMaxAge: 60000,
125
139
  timestampMaxFuture: 60000,
140
+ // Resource limits
141
+ maxOffersPerUser: 20,
142
+ maxTotalOffers: 10000,
143
+ maxTotalCredentials: 50000,
144
+ maxIceCandidatesPerOffer: 50,
145
+ credentialsPerIpPerHour: 10,
146
+ requestsPerIpPerSecond: 50,
126
147
  } as const;
127
148
 
128
149
  /**
@@ -160,6 +181,13 @@ export function buildWorkerConfig(env: {
160
181
  timestampMaxAge: CONFIG_DEFAULTS.timestampMaxAge,
161
182
  timestampMaxFuture: CONFIG_DEFAULTS.timestampMaxFuture,
162
183
  masterEncryptionKey: env.MASTER_ENCRYPTION_KEY,
184
+ // Resource limits
185
+ maxOffersPerUser: CONFIG_DEFAULTS.maxOffersPerUser,
186
+ maxTotalOffers: CONFIG_DEFAULTS.maxTotalOffers,
187
+ maxTotalCredentials: CONFIG_DEFAULTS.maxTotalCredentials,
188
+ maxIceCandidatesPerOffer: CONFIG_DEFAULTS.maxIceCandidatesPerOffer,
189
+ credentialsPerIpPerHour: CONFIG_DEFAULTS.credentialsPerIpPerHour,
190
+ requestsPerIpPerSecond: CONFIG_DEFAULTS.requestsPerIpPerSecond,
163
191
  };
164
192
  }
165
193
 
package/src/rpc.ts CHANGED
@@ -16,13 +16,13 @@ const MAX_PAGE_SIZE = 100;
16
16
 
17
17
  // ===== Rate Limiting =====
18
18
 
19
- // Rate limiting for credential generation (per IP)
19
+ // Rate limiting windows (these are fixed, limits come from config)
20
20
  // NOTE: Uses fixed-window rate limiting with full window reset on expiry
21
- // - Window starts on first request and expires after CREDENTIAL_RATE_WINDOW
21
+ // - Window starts on first request and expires after window duration
22
22
  // - When window expires, counter resets to 0 and new window starts
23
23
  // - This is simpler than sliding windows but may allow bursts at window boundaries
24
- const CREDENTIAL_RATE_LIMIT = 1; // Max credentials per second per IP
25
- const CREDENTIAL_RATE_WINDOW = 1000; // 1 second in milliseconds
24
+ const CREDENTIAL_RATE_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
25
+ const REQUEST_RATE_WINDOW = 1000; // 1 second in milliseconds
26
26
 
27
27
  /**
28
28
  * Check JSON object depth to prevent stack overflow from deeply nested objects
@@ -100,6 +100,9 @@ export const ErrorCodes = {
100
100
  SDP_TOO_LARGE: 'SDP_TOO_LARGE',
101
101
  BATCH_TOO_LARGE: 'BATCH_TOO_LARGE',
102
102
  RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
103
+ TOO_MANY_OFFERS_PER_USER: 'TOO_MANY_OFFERS_PER_USER',
104
+ STORAGE_FULL: 'STORAGE_FULL',
105
+ TOO_MANY_ICE_CANDIDATES: 'TOO_MANY_ICE_CANDIDATES',
103
106
 
104
107
  // Generic errors
105
108
  INTERNAL_ERROR: 'INTERNAL_ERROR',
@@ -276,6 +279,15 @@ const handlers: Record<string, RpcHandler> = {
276
279
  * SECURITY: Rate limited per IP to prevent abuse (database-backed for multi-instance support)
277
280
  */
278
281
  async generateCredentials(params: GenerateCredentialsParams, name, timestamp, signature, storage, config, request: RpcRequest & { clientIp?: string }) {
282
+ // Check total credentials limit
283
+ const credentialCount = await storage.getCredentialCount();
284
+ if (credentialCount >= config.maxTotalCredentials) {
285
+ throw new RpcError(
286
+ ErrorCodes.STORAGE_FULL,
287
+ `Server credential limit reached (${config.maxTotalCredentials}). Try again later.`
288
+ );
289
+ }
290
+
279
291
  // Rate limiting check (IP-based, stored in database)
280
292
  // SECURITY: Use stricter global rate limit for requests without identifiable IP
281
293
  let rateLimitKey: string;
@@ -289,7 +301,7 @@ const handlers: Record<string, RpcHandler> = {
289
301
  rateLimit = 2; // Only 2 credentials per hour globally for all unknown IPs combined
290
302
  } else {
291
303
  rateLimitKey = `cred_gen:${request.clientIp}`;
292
- rateLimit = CREDENTIAL_RATE_LIMIT; // 10 per hour per IP
304
+ rateLimit = config.credentialsPerIpPerHour;
293
305
  }
294
306
 
295
307
  const allowed = await storage.checkRateLimit(
@@ -301,7 +313,7 @@ const handlers: Record<string, RpcHandler> = {
301
313
  if (!allowed) {
302
314
  throw new RpcError(
303
315
  ErrorCodes.RATE_LIMIT_EXCEEDED,
304
- `Rate limit exceeded. Maximum ${rateLimit} credential per second${request.clientIp ? ' per IP' : ' (global limit for unidentified IPs)'}.`
316
+ `Rate limit exceeded. Maximum ${rateLimit} credentials per hour${request.clientIp ? ' per IP' : ' (global limit for unidentified IPs)'}.`
305
317
  );
306
318
  }
307
319
 
@@ -453,6 +465,24 @@ const handlers: Record<string, RpcHandler> = {
453
465
  );
454
466
  }
455
467
 
468
+ // Check per-user offer limit
469
+ const userOfferCount = await storage.getOfferCountByUsername(name);
470
+ if (userOfferCount + offers.length > config.maxOffersPerUser) {
471
+ throw new RpcError(
472
+ ErrorCodes.TOO_MANY_OFFERS_PER_USER,
473
+ `User offer limit exceeded. You have ${userOfferCount} offers, limit is ${config.maxOffersPerUser}.`
474
+ );
475
+ }
476
+
477
+ // Check total offers limit
478
+ const totalOfferCount = await storage.getOfferCount();
479
+ if (totalOfferCount + offers.length > config.maxTotalOffers) {
480
+ throw new RpcError(
481
+ ErrorCodes.STORAGE_FULL,
482
+ `Server offer limit reached (${config.maxTotalOffers}). Try again later.`
483
+ );
484
+ }
485
+
456
486
  // Validate each offer has valid SDP
457
487
  offers.forEach((offer, index) => {
458
488
  if (!offer || typeof offer !== 'object') {
@@ -723,6 +753,15 @@ const handlers: Record<string, RpcHandler> = {
723
753
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
724
754
  }
725
755
 
756
+ // Check ICE candidates limit per offer
757
+ const currentCandidateCount = await storage.getIceCandidateCount(offerId);
758
+ if (currentCandidateCount + candidates.length > config.maxIceCandidatesPerOffer) {
759
+ throw new RpcError(
760
+ ErrorCodes.TOO_MANY_ICE_CANDIDATES,
761
+ `ICE candidate limit exceeded for offer. Current: ${currentCandidateCount}, limit: ${config.maxIceCandidatesPerOffer}.`
762
+ );
763
+ }
764
+
726
765
  const role = offer.username === name ? 'offerer' : 'answerer';
727
766
  const count = await storage.addIceCandidates(
728
767
  offerId,
@@ -807,6 +846,25 @@ export async function handleRpc(
807
846
  ctx.req.header('x-forwarded-for')?.split(',')[0].trim() ||
808
847
  undefined; // Don't use fallback - let handlers decide how to handle missing IP
809
848
 
849
+ // General request rate limiting (per IP per second)
850
+ if (clientIp) {
851
+ const rateLimitKey = `req:${clientIp}`;
852
+ const allowed = await storage.checkRateLimit(
853
+ rateLimitKey,
854
+ config.requestsPerIpPerSecond,
855
+ REQUEST_RATE_WINDOW
856
+ );
857
+
858
+ if (!allowed) {
859
+ // Return error for all requests in the batch
860
+ return requests.map(() => ({
861
+ success: false,
862
+ error: `Rate limit exceeded. Maximum ${config.requestsPerIpPerSecond} requests per second per IP.`,
863
+ errorCode: ErrorCodes.RATE_LIMIT_EXCEEDED,
864
+ }));
865
+ }
866
+ }
867
+
810
868
  // Read auth headers (same for all requests in batch)
811
869
  const name = ctx.req.header('X-Name');
812
870
  const timestampHeader = ctx.req.header('X-Timestamp');
package/src/storage/d1.ts CHANGED
@@ -631,6 +631,32 @@ export class D1Storage implements Storage {
631
631
  // Connections are managed by the Cloudflare Workers runtime
632
632
  }
633
633
 
634
+ // ===== Count Methods (for resource limits) =====
635
+
636
+ async getOfferCount(): Promise<number> {
637
+ const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers').first() as { count: number } | null;
638
+ return result?.count ?? 0;
639
+ }
640
+
641
+ async getOfferCountByUsername(username: string): Promise<number> {
642
+ const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE username = ?')
643
+ .bind(username)
644
+ .first() as { count: number } | null;
645
+ return result?.count ?? 0;
646
+ }
647
+
648
+ async getCredentialCount(): Promise<number> {
649
+ const result = await this.db.prepare('SELECT COUNT(*) as count FROM credentials').first() as { count: number } | null;
650
+ return result?.count ?? 0;
651
+ }
652
+
653
+ async getIceCandidateCount(offerId: string): Promise<number> {
654
+ const result = await this.db.prepare('SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?')
655
+ .bind(offerId)
656
+ .first() as { count: number } | null;
657
+ return result?.count ?? 0;
658
+ }
659
+
634
660
  // ===== Helper Methods =====
635
661
 
636
662
  /**
@@ -522,6 +522,26 @@ export class MemoryStorage implements Storage {
522
522
  this.offersByAnswerer.clear();
523
523
  }
524
524
 
525
+ // ===== Count Methods (for resource limits) =====
526
+
527
+ async getOfferCount(): Promise<number> {
528
+ return this.offers.size;
529
+ }
530
+
531
+ async getOfferCountByUsername(username: string): Promise<number> {
532
+ const offerIds = this.offersByUsername.get(username);
533
+ return offerIds ? offerIds.size : 0;
534
+ }
535
+
536
+ async getCredentialCount(): Promise<number> {
537
+ return this.credentials.size;
538
+ }
539
+
540
+ async getIceCandidateCount(offerId: string): Promise<number> {
541
+ const candidates = this.iceCandidates.get(offerId);
542
+ return candidates ? candidates.length : 0;
543
+ }
544
+
525
545
  // ===== Helper Methods =====
526
546
 
527
547
  private removeOfferFromIndexes(offer: Offer): void {
@@ -558,6 +558,34 @@ export class MySQLStorage implements Storage {
558
558
  await this.pool.end();
559
559
  }
560
560
 
561
+ // ===== Count Methods (for resource limits) =====
562
+
563
+ async getOfferCount(): Promise<number> {
564
+ const [rows] = await this.pool.query<RowDataPacket[]>('SELECT COUNT(*) as count FROM offers');
565
+ return Number(rows[0].count);
566
+ }
567
+
568
+ async getOfferCountByUsername(username: string): Promise<number> {
569
+ const [rows] = await this.pool.query<RowDataPacket[]>(
570
+ 'SELECT COUNT(*) as count FROM offers WHERE username = ?',
571
+ [username]
572
+ );
573
+ return Number(rows[0].count);
574
+ }
575
+
576
+ async getCredentialCount(): Promise<number> {
577
+ const [rows] = await this.pool.query<RowDataPacket[]>('SELECT COUNT(*) as count FROM credentials');
578
+ return Number(rows[0].count);
579
+ }
580
+
581
+ async getIceCandidateCount(offerId: string): Promise<number> {
582
+ const [rows] = await this.pool.query<RowDataPacket[]>(
583
+ 'SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?',
584
+ [offerId]
585
+ );
586
+ return Number(rows[0].count);
587
+ }
588
+
561
589
  // ===== Helper Methods =====
562
590
 
563
591
  private rowToOffer(row: RowDataPacket): Offer {
@@ -565,6 +565,34 @@ export class PostgreSQLStorage implements Storage {
565
565
  await this.pool.end();
566
566
  }
567
567
 
568
+ // ===== Count Methods (for resource limits) =====
569
+
570
+ async getOfferCount(): Promise<number> {
571
+ const result = await this.pool.query('SELECT COUNT(*) as count FROM offers');
572
+ return Number(result.rows[0].count);
573
+ }
574
+
575
+ async getOfferCountByUsername(username: string): Promise<number> {
576
+ const result = await this.pool.query(
577
+ 'SELECT COUNT(*) as count FROM offers WHERE username = $1',
578
+ [username]
579
+ );
580
+ return Number(result.rows[0].count);
581
+ }
582
+
583
+ async getCredentialCount(): Promise<number> {
584
+ const result = await this.pool.query('SELECT COUNT(*) as count FROM credentials');
585
+ return Number(result.rows[0].count);
586
+ }
587
+
588
+ async getIceCandidateCount(offerId: string): Promise<number> {
589
+ const result = await this.pool.query(
590
+ 'SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = $1',
591
+ [offerId]
592
+ );
593
+ return Number(result.rows[0].count);
594
+ }
595
+
568
596
  // ===== Helper Methods =====
569
597
 
570
598
  private rowToOffer(row: any): Offer {
@@ -642,6 +642,28 @@ export class SQLiteStorage implements Storage {
642
642
  this.db.close();
643
643
  }
644
644
 
645
+ // ===== Count Methods (for resource limits) =====
646
+
647
+ async getOfferCount(): Promise<number> {
648
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM offers').get() as { count: number };
649
+ return result.count;
650
+ }
651
+
652
+ async getOfferCountByUsername(username: string): Promise<number> {
653
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE username = ?').get(username) as { count: number };
654
+ return result.count;
655
+ }
656
+
657
+ async getCredentialCount(): Promise<number> {
658
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM credentials').get() as { count: number };
659
+ return result.count;
660
+ }
661
+
662
+ async getIceCandidateCount(offerId: string): Promise<number> {
663
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?').get(offerId) as { count: number };
664
+ return result.count;
665
+ }
666
+
645
667
  // ===== Helper Methods =====
646
668
 
647
669
  /**
@@ -282,4 +282,32 @@ export interface Storage {
282
282
  * Closes the storage connection and releases resources
283
283
  */
284
284
  close(): Promise<void>;
285
+
286
+ // ===== Count Methods (for resource limits) =====
287
+
288
+ /**
289
+ * Gets total number of offers in storage
290
+ * @returns Total offer count
291
+ */
292
+ getOfferCount(): Promise<number>;
293
+
294
+ /**
295
+ * Gets number of offers for a specific user
296
+ * @param username Username identifier
297
+ * @returns Offer count for user
298
+ */
299
+ getOfferCountByUsername(username: string): Promise<number>;
300
+
301
+ /**
302
+ * Gets total number of credentials in storage
303
+ * @returns Total credential count
304
+ */
305
+ getCredentialCount(): Promise<number>;
306
+
307
+ /**
308
+ * Gets number of ICE candidates for a specific offer
309
+ * @param offerId Offer identifier
310
+ * @returns ICE candidate count for offer
311
+ */
312
+ getIceCandidateCount(offerId: string): Promise<number>;
285
313
  }
package/wrangler.toml CHANGED
@@ -6,7 +6,7 @@ compatibility_flags = ["nodejs_compat"]
6
6
  workers_dev = true
7
7
  preview_urls = true
8
8
 
9
- routes = [{ pattern = "api.ronde.vu/*", zone_name = "ronde.vu" }]
9
+ routes = [{ pattern = "test.ronde.vu/*", zone_name = "ronde.vu" }]
10
10
 
11
11
  # Cleanup runs every 5 minutes
12
12
  [triggers]