@aegis-fluxion/core 0.7.0 → 0.7.1

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 CHANGED
@@ -4,7 +4,7 @@ Low-level E2E-encrypted WebSocket primitives for the `aegis-fluxion` ecosystem.
4
4
 
5
5
  If you prefer a single user-facing package, use [`aegis-fluxion`](../aegis-fluxion/README.md).
6
6
 
7
- Version: **0.7.0**
7
+ Version: **0.7.1**
8
8
 
9
9
  ---
10
10
 
@@ -20,6 +20,7 @@ Version: **0.7.0**
20
20
  - **Server middleware pipeline** via `SecureServer.use(...)`
21
21
  - Middleware phases: `connection`, `incoming`, `outgoing`
22
22
  - Per-socket middleware metadata available as `SecureServerClient.metadata`
23
+ - **Rate limiting & DDoS shield** with per-connection/per-IP controls
23
24
  - Optional MCP bridge package: `@aegis-fluxion/mcp-adapter`
24
25
 
25
26
  ---
@@ -107,6 +108,40 @@ Notes:
107
108
 
108
109
  ---
109
110
 
111
+ ## Rate Limiting & DDoS Protection (0.7.1)
112
+
113
+ `SecureServer` can enforce burst limits per connection and per source IP before event handlers run.
114
+
115
+ ```ts
116
+ import { SecureServer } from "@aegis-fluxion/core";
117
+
118
+ const server = new SecureServer({
119
+ host: "127.0.0.1",
120
+ port: 8080,
121
+ rateLimit: {
122
+ enabled: true,
123
+ windowMs: 1_000,
124
+ maxEventsPerConnection: 120,
125
+ maxEventsPerIp: 300,
126
+ action: "throttle",
127
+ throttleMs: 150,
128
+ maxThrottleMs: 2_000,
129
+ disconnectAfterViolations: 4,
130
+ disconnectCode: 1013,
131
+ disconnectReason: "Rate limit exceeded. Please retry later."
132
+ }
133
+ });
134
+ ```
135
+
136
+ Behavior summary:
137
+
138
+ - When limits are exceeded, the server can **throttle** or **disconnect** the peer.
139
+ - Throttle mode delays the first over-limit message and drops subsequent flood packets during the throttle window.
140
+ - Disconnect mode closes abusive sockets with your configured close code/reason.
141
+ - Source IP is resolved from `x-forwarded-for` (first hop) or socket remote address.
142
+
143
+ ---
144
+
110
145
  ## MCP Adapter Integration (0.7.0)
111
146
 
112
147
  Use `@aegis-fluxion/mcp-adapter` to carry MCP JSON-RPC messages through your encrypted core
@@ -265,6 +300,8 @@ client.on("ready", async () => {
265
300
  - `SecureServerConnectionMiddlewareContext`
266
301
  - `SecureServerMessageMiddlewareContext`
267
302
  - `SecureServerMiddlewareNext`
303
+ - `SecureServerRateLimitOptions`
304
+ - `SecureServerRateLimitAction`
268
305
 
269
306
  ### `SecureClient`
270
307
 
@@ -287,6 +324,7 @@ client.on("ready", async () => {
287
324
  - Authentication tags are verified on every packet (tampered packets are dropped).
288
325
  - Internal transport events are reserved (`__handshake`, `__rpc:req`, `__rpc:res`).
289
326
  - Pending ACK requests are rejected on timeout/disconnect.
327
+ - Overload traffic can be throttled or disconnected before custom handlers are invoked.
290
328
  - Middleware-level policy rejection uses WebSocket close code `1008`.
291
329
 
292
330
  ---
package/dist/index.cjs CHANGED
@@ -32,6 +32,14 @@ var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
32
32
  var DEFAULT_RECONNECT_FACTOR = 2;
33
33
  var DEFAULT_RECONNECT_JITTER_RATIO = 0.2;
34
34
  var DEFAULT_RPC_TIMEOUT_MS = 5e3;
35
+ var DEFAULT_RATE_LIMIT_WINDOW_MS = 1e3;
36
+ var DEFAULT_RATE_LIMIT_MAX_EVENTS_PER_CONNECTION = 120;
37
+ var DEFAULT_RATE_LIMIT_MAX_EVENTS_PER_IP = 300;
38
+ var DEFAULT_RATE_LIMIT_THROTTLE_MS = 150;
39
+ var DEFAULT_RATE_LIMIT_MAX_THROTTLE_MS = 2e3;
40
+ var DEFAULT_RATE_LIMIT_DISCONNECT_AFTER_VIOLATIONS = 4;
41
+ var DEFAULT_RATE_LIMIT_CLOSE_CODE = 1013;
42
+ var DEFAULT_RATE_LIMIT_CLOSE_REASON = "Rate limit exceeded. Please retry later.";
35
43
  function normalizeToError(error, fallbackMessage) {
36
44
  if (error instanceof Error) {
37
45
  return error;
@@ -65,6 +73,11 @@ function rawDataToBuffer(rawData) {
65
73
  }
66
74
  return Buffer.from(rawData);
67
75
  }
76
+ function delay(ms) {
77
+ return new Promise((resolve) => {
78
+ setTimeout(resolve, ms);
79
+ });
80
+ }
68
81
  function isBlobValue(value) {
69
82
  return typeof Blob !== "undefined" && value instanceof Blob;
70
83
  }
@@ -357,6 +370,7 @@ function decryptSerializedEnvelope(rawData, encryptionKey) {
357
370
  var SecureServer = class {
358
371
  socketServer;
359
372
  heartbeatConfig;
373
+ rateLimitConfig;
360
374
  heartbeatIntervalHandle = null;
361
375
  clientsById = /* @__PURE__ */ new Map();
362
376
  clientIdBySocket = /* @__PURE__ */ new Map();
@@ -375,9 +389,13 @@ var SecureServer = class {
375
389
  heartbeatStateBySocket = /* @__PURE__ */ new WeakMap();
376
390
  roomMembersByName = /* @__PURE__ */ new Map();
377
391
  roomNamesByClientId = /* @__PURE__ */ new Map();
392
+ clientIpByClientId = /* @__PURE__ */ new Map();
393
+ rateLimitBucketsByClientId = /* @__PURE__ */ new Map();
394
+ rateLimitBucketsByIp = /* @__PURE__ */ new Map();
378
395
  constructor(options) {
379
- const { heartbeat, ...socketServerOptions } = options;
396
+ const { heartbeat, rateLimit, ...socketServerOptions } = options;
380
397
  this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
398
+ this.rateLimitConfig = this.resolveRateLimitConfig(rateLimit);
381
399
  this.socketServer = new WebSocket.WebSocketServer(socketServerOptions);
382
400
  this.bindSocketServerEvents();
383
401
  this.startHeartbeatLoop();
@@ -559,6 +577,9 @@ var SecureServer = class {
559
577
  client.socket.close(code, reason);
560
578
  }
561
579
  }
580
+ this.rateLimitBucketsByClientId.clear();
581
+ this.rateLimitBucketsByIp.clear();
582
+ this.clientIpByClientId.clear();
562
583
  this.socketServer.close();
563
584
  } catch (error) {
564
585
  this.notifyError(normalizeToError(error, "Failed to close server."));
@@ -579,6 +600,211 @@ var SecureServer = class {
579
600
  timeoutMs
580
601
  };
581
602
  }
603
+ resolveRateLimitConfig(rateLimitOptions) {
604
+ const windowMs = rateLimitOptions?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
605
+ const maxEventsPerConnection = rateLimitOptions?.maxEventsPerConnection ?? DEFAULT_RATE_LIMIT_MAX_EVENTS_PER_CONNECTION;
606
+ const maxEventsPerIp = rateLimitOptions?.maxEventsPerIp ?? DEFAULT_RATE_LIMIT_MAX_EVENTS_PER_IP;
607
+ const action = rateLimitOptions?.action ?? "throttle";
608
+ const throttleMs = rateLimitOptions?.throttleMs ?? DEFAULT_RATE_LIMIT_THROTTLE_MS;
609
+ const maxThrottleMs = rateLimitOptions?.maxThrottleMs ?? DEFAULT_RATE_LIMIT_MAX_THROTTLE_MS;
610
+ const disconnectAfterViolations = rateLimitOptions?.disconnectAfterViolations ?? DEFAULT_RATE_LIMIT_DISCONNECT_AFTER_VIOLATIONS;
611
+ const disconnectCode = rateLimitOptions?.disconnectCode ?? DEFAULT_RATE_LIMIT_CLOSE_CODE;
612
+ const disconnectReason = rateLimitOptions?.disconnectReason ?? DEFAULT_RATE_LIMIT_CLOSE_REASON;
613
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
614
+ throw new Error("Server rateLimit windowMs must be a positive number.");
615
+ }
616
+ if (!Number.isFinite(maxEventsPerConnection) || maxEventsPerConnection <= 0) {
617
+ throw new Error(
618
+ "Server rateLimit maxEventsPerConnection must be a positive number."
619
+ );
620
+ }
621
+ if (!Number.isFinite(maxEventsPerIp) || maxEventsPerIp <= 0) {
622
+ throw new Error("Server rateLimit maxEventsPerIp must be a positive number.");
623
+ }
624
+ if (action !== "throttle" && action !== "disconnect") {
625
+ throw new Error('Server rateLimit action must be either "throttle" or "disconnect".');
626
+ }
627
+ if (!Number.isFinite(throttleMs) || throttleMs <= 0) {
628
+ throw new Error("Server rateLimit throttleMs must be a positive number.");
629
+ }
630
+ if (!Number.isFinite(maxThrottleMs) || maxThrottleMs <= 0) {
631
+ throw new Error("Server rateLimit maxThrottleMs must be a positive number.");
632
+ }
633
+ if (maxThrottleMs < throttleMs) {
634
+ throw new Error(
635
+ "Server rateLimit maxThrottleMs must be greater than or equal to throttleMs."
636
+ );
637
+ }
638
+ if (!Number.isInteger(disconnectAfterViolations) || disconnectAfterViolations <= 0) {
639
+ throw new Error(
640
+ "Server rateLimit disconnectAfterViolations must be a positive integer."
641
+ );
642
+ }
643
+ if (!Number.isInteger(disconnectCode) || disconnectCode < 1e3 || disconnectCode > 4999) {
644
+ throw new Error("Server rateLimit disconnectCode must be a valid WebSocket close code.");
645
+ }
646
+ return {
647
+ enabled: rateLimitOptions?.enabled ?? true,
648
+ windowMs,
649
+ maxEventsPerConnection,
650
+ maxEventsPerIp,
651
+ action,
652
+ throttleMs,
653
+ maxThrottleMs,
654
+ disconnectAfterViolations,
655
+ disconnectCode,
656
+ disconnectReason
657
+ };
658
+ }
659
+ createRateLimitBucket(now) {
660
+ return {
661
+ windowStartedAt: now,
662
+ count: 0,
663
+ violationCount: 0,
664
+ throttleUntil: 0,
665
+ lastSeenAt: now
666
+ };
667
+ }
668
+ getOrCreateRateLimitBucket(map, key, now) {
669
+ const existingBucket = map.get(key);
670
+ if (existingBucket) {
671
+ return existingBucket;
672
+ }
673
+ const bucket = this.createRateLimitBucket(now);
674
+ map.set(key, bucket);
675
+ return bucket;
676
+ }
677
+ updateRateLimitBucket(bucket, now) {
678
+ if (now - bucket.windowStartedAt >= this.rateLimitConfig.windowMs) {
679
+ bucket.windowStartedAt = now;
680
+ bucket.count = 0;
681
+ bucket.violationCount = 0;
682
+ bucket.throttleUntil = 0;
683
+ }
684
+ bucket.count += 1;
685
+ bucket.lastSeenAt = now;
686
+ }
687
+ pruneRateLimitBucketMap(map, now, maxIdleMs) {
688
+ for (const [key, bucket] of map.entries()) {
689
+ if (now - bucket.lastSeenAt >= maxIdleMs) {
690
+ map.delete(key);
691
+ }
692
+ }
693
+ }
694
+ pruneRateLimitBuckets(now) {
695
+ const maxIdleMs = this.rateLimitConfig.windowMs * 4;
696
+ this.pruneRateLimitBucketMap(this.rateLimitBucketsByClientId, now, maxIdleMs);
697
+ this.pruneRateLimitBucketMap(this.rateLimitBucketsByIp, now, maxIdleMs);
698
+ }
699
+ normalizeIpAddress(ipAddress) {
700
+ let normalized = ipAddress.trim().toLowerCase();
701
+ if (normalized.startsWith("::ffff:")) {
702
+ normalized = normalized.slice(7);
703
+ }
704
+ if (normalized.startsWith("[") && normalized.endsWith("]")) {
705
+ normalized = normalized.slice(1, -1);
706
+ }
707
+ const zoneIndex = normalized.indexOf("%");
708
+ if (zoneIndex >= 0) {
709
+ normalized = normalized.slice(0, zoneIndex);
710
+ }
711
+ return normalized.length > 0 ? normalized : "unknown";
712
+ }
713
+ resolveClientIp(request) {
714
+ const forwardedHeader = request.headers["x-forwarded-for"];
715
+ const forwardedValue = Array.isArray(forwardedHeader) ? forwardedHeader[0] : forwardedHeader;
716
+ if (typeof forwardedValue === "string") {
717
+ const firstForwardedIp = forwardedValue.split(",").map((item) => item.trim()).find((item) => item.length > 0);
718
+ if (firstForwardedIp) {
719
+ return this.normalizeIpAddress(firstForwardedIp);
720
+ }
721
+ }
722
+ return this.normalizeIpAddress(request.socket.remoteAddress ?? "unknown");
723
+ }
724
+ isIpStillConnected(ipAddress) {
725
+ for (const connectedIp of this.clientIpByClientId.values()) {
726
+ if (connectedIp === ipAddress) {
727
+ return true;
728
+ }
729
+ }
730
+ return false;
731
+ }
732
+ evaluateIncomingRateLimit(client) {
733
+ const noLimitDecision = {
734
+ shouldDisconnect: false,
735
+ shouldDrop: false,
736
+ throttleDelayMs: 0
737
+ };
738
+ if (!this.rateLimitConfig.enabled) {
739
+ return noLimitDecision;
740
+ }
741
+ const now = Date.now();
742
+ const clientBucket = this.getOrCreateRateLimitBucket(
743
+ this.rateLimitBucketsByClientId,
744
+ client.id,
745
+ now
746
+ );
747
+ this.updateRateLimitBucket(clientBucket, now);
748
+ const clientIp = this.clientIpByClientId.get(client.id);
749
+ const ipBucket = clientIp ? this.getOrCreateRateLimitBucket(this.rateLimitBucketsByIp, clientIp, now) : null;
750
+ if (ipBucket) {
751
+ this.updateRateLimitBucket(ipBucket, now);
752
+ }
753
+ const activeThrottleUntil = Math.max(
754
+ clientBucket.throttleUntil,
755
+ ipBucket?.throttleUntil ?? 0
756
+ );
757
+ if (activeThrottleUntil > now) {
758
+ return {
759
+ shouldDisconnect: false,
760
+ shouldDrop: true,
761
+ throttleDelayMs: 0
762
+ };
763
+ }
764
+ const isConnectionLimitExceeded = clientBucket.count > this.rateLimitConfig.maxEventsPerConnection;
765
+ const isIpLimitExceeded = ipBucket ? ipBucket.count > this.rateLimitConfig.maxEventsPerIp : false;
766
+ if (!isConnectionLimitExceeded && !isIpLimitExceeded) {
767
+ if (this.rateLimitBucketsByClientId.size > 1024 || this.rateLimitBucketsByIp.size > 1024) {
768
+ this.pruneRateLimitBuckets(now);
769
+ }
770
+ return noLimitDecision;
771
+ }
772
+ if (isConnectionLimitExceeded) {
773
+ clientBucket.violationCount += 1;
774
+ }
775
+ if (ipBucket && isIpLimitExceeded) {
776
+ ipBucket.violationCount += 1;
777
+ }
778
+ const violationCount = Math.max(
779
+ clientBucket.violationCount,
780
+ ipBucket?.violationCount ?? 0
781
+ );
782
+ const shouldDisconnect = this.rateLimitConfig.action === "disconnect" || violationCount >= this.rateLimitConfig.disconnectAfterViolations;
783
+ if (shouldDisconnect) {
784
+ return {
785
+ shouldDisconnect: true,
786
+ shouldDrop: true,
787
+ throttleDelayMs: 0
788
+ };
789
+ }
790
+ const throttleDelayMs = Math.min(
791
+ this.rateLimitConfig.maxThrottleMs,
792
+ Math.max(
793
+ this.rateLimitConfig.throttleMs,
794
+ this.rateLimitConfig.throttleMs * violationCount
795
+ )
796
+ );
797
+ const throttleUntil = now + throttleDelayMs;
798
+ clientBucket.throttleUntil = throttleUntil;
799
+ if (ipBucket) {
800
+ ipBucket.throttleUntil = throttleUntil;
801
+ }
802
+ return {
803
+ shouldDisconnect: false,
804
+ shouldDrop: false,
805
+ throttleDelayMs
806
+ };
807
+ }
582
808
  startHeartbeatLoop() {
583
809
  if (!this.heartbeatConfig.enabled || this.heartbeatIntervalHandle) {
584
810
  return;
@@ -681,6 +907,8 @@ var SecureServer = class {
681
907
  try {
682
908
  const clientId = crypto.randomUUID();
683
909
  const handshakeState = this.createServerHandshakeState();
910
+ const clientIp = this.resolveClientIp(request);
911
+ connectionMetadata.set("network.ip", clientIp);
684
912
  const client = this.createSecureServerClient(
685
913
  clientId,
686
914
  socket,
@@ -689,6 +917,7 @@ var SecureServer = class {
689
917
  );
690
918
  this.clientsById.set(clientId, client);
691
919
  this.clientIdBySocket.set(socket, clientId);
920
+ this.clientIpByClientId.set(clientId, clientIp);
692
921
  this.handshakeStateBySocket.set(socket, handshakeState);
693
922
  this.pendingPayloadsBySocket.set(socket, []);
694
923
  this.pendingRpcRequestsBySocket.set(socket, /* @__PURE__ */ new Map());
@@ -723,6 +952,35 @@ var SecureServer = class {
723
952
  }
724
953
  async handleIncomingMessage(client, rawData) {
725
954
  try {
955
+ const rateLimitDecision = this.evaluateIncomingRateLimit(client);
956
+ if (rateLimitDecision.shouldDisconnect) {
957
+ this.notifyError(
958
+ new Error(
959
+ `Rate limit disconnect triggered for client ${client.id}.`
960
+ )
961
+ );
962
+ if (client.socket.readyState === WebSocket__default.default.OPEN || client.socket.readyState === WebSocket__default.default.CONNECTING) {
963
+ client.socket.close(
964
+ this.rateLimitConfig.disconnectCode,
965
+ this.rateLimitConfig.disconnectReason
966
+ );
967
+ }
968
+ return;
969
+ }
970
+ if (rateLimitDecision.shouldDrop) {
971
+ return;
972
+ }
973
+ if (rateLimitDecision.throttleDelayMs > 0) {
974
+ this.notifyError(
975
+ new Error(
976
+ `Rate limit throttle applied to client ${client.id} for ${rateLimitDecision.throttleDelayMs}ms.`
977
+ )
978
+ );
979
+ await delay(rateLimitDecision.throttleDelayMs);
980
+ if (client.socket.readyState !== WebSocket__default.default.OPEN) {
981
+ return;
982
+ }
983
+ }
726
984
  let envelope = null;
727
985
  try {
728
986
  envelope = parseEnvelope(rawData);
@@ -784,6 +1042,12 @@ var SecureServer = class {
784
1042
  client.leaveAll();
785
1043
  this.clientsById.delete(client.id);
786
1044
  this.clientIdBySocket.delete(client.socket);
1045
+ const disconnectedIp = this.clientIpByClientId.get(client.id);
1046
+ this.clientIpByClientId.delete(client.id);
1047
+ this.rateLimitBucketsByClientId.delete(client.id);
1048
+ if (disconnectedIp && !this.isIpStillConnected(disconnectedIp)) {
1049
+ this.rateLimitBucketsByIp.delete(disconnectedIp);
1050
+ }
787
1051
  this.handshakeStateBySocket.delete(client.socket);
788
1052
  this.sharedSecretBySocket.delete(client.socket);
789
1053
  this.encryptionKeyBySocket.delete(client.socket);