serverless-ircd 0.10.0 → 0.11.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.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Per-connection inbound frame-rate window (`admitFrameWindow`).
3
+ *
4
+ * Pure sliding-window admission policy shared by the adapters' inbound
5
+ * frame gates: a connection may deliver at most `maxFrames`
6
+ * frames inside any `windowMs` window; the frame that would exceed the
7
+ * budget is rejected so the adapter can close the connection before the
8
+ * actor / storage write runs.
9
+ *
10
+ * Coverage matrix: under-threshold, at-threshold,
11
+ * over-threshold, and the sliding-window decay.
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import {
16
+ FRAME_FLOOD_CLOSE_CODE,
17
+ FRAME_FLOOD_ERROR_LINE,
18
+ admitFrameWindow,
19
+ } from '../src/frame-rate-limit';
20
+
21
+ describe('admitFrameWindow', () => {
22
+ it('admits a frame while the window holds fewer than maxFrames entries (under-threshold)', () => {
23
+ const decision = admitFrameWindow([1_000, 2_000], 3_000, 50, 5_000);
24
+ expect(decision.admitted).toBe(true);
25
+ expect(decision.times).toEqual([1_000, 2_000, 3_000]);
26
+ });
27
+
28
+ it('admits an empty window (first frame of a connection)', () => {
29
+ const decision = admitFrameWindow([], 42, 50, 5_000);
30
+ expect(decision.admitted).toBe(true);
31
+ expect(decision.times).toEqual([42]);
32
+ });
33
+
34
+ it('admits exactly the maxFrames-th frame in the window (at-threshold)', () => {
35
+ const times = Array.from({ length: 49 }, (_, i) => i + 1);
36
+ const decision = admitFrameWindow(times, 50, 50, 5_000);
37
+ expect(decision.admitted).toBe(true);
38
+ expect(decision.times.length).toBe(50);
39
+ });
40
+
41
+ it('rejects the maxFrames+1-th frame within the window (over-threshold)', () => {
42
+ const times = Array.from({ length: 50 }, (_, i) => i + 1);
43
+ const decision = admitFrameWindow(times, 51, 50, 5_000);
44
+ expect(decision.admitted).toBe(false);
45
+ // The rejected frame is NOT recorded; the caller keeps the pruned view.
46
+ expect(decision.times.length).toBe(50);
47
+ });
48
+
49
+ it('ages entries out of the window so old frames stop counting (sliding-window decay)', () => {
50
+ // 50 frames at t=0..49; 6 s after the first frame every entry has
51
+ // aged out of the 5 s window, so the connection starts fresh.
52
+ const times = Array.from({ length: 50 }, (_, i) => i);
53
+ const decision = admitFrameWindow(times, 6_049, 50, 5_000);
54
+ expect(decision.admitted).toBe(true);
55
+ expect(decision.times).toEqual([6_049]);
56
+ });
57
+
58
+ it('partially decays a window (entries inside the window still count)', () => {
59
+ // Frames at t=0 and t=4_000; at t=5_500 only the t=0 entry has aged
60
+ // out of the 5 s window — the recent one still counts, so this frame
61
+ // fits a max=2 budget.
62
+ const decision = admitFrameWindow([0, 4_000], 5_500, 2, 5_000);
63
+ expect(decision.admitted).toBe(true);
64
+ expect(decision.times).toEqual([4_000, 5_500]);
65
+ });
66
+
67
+ it('keeps an entry at exactly now - windowMs inside the window (inclusive boundary)', () => {
68
+ const decision = admitFrameWindow([1_000], 6_000, 1, 5_000);
69
+ // 1_000 >= 6_000 - 5_000 → still counted, so this frame exceeds max=1.
70
+ expect(decision.admitted).toBe(false);
71
+ expect(decision.times).toEqual([1_000]);
72
+ });
73
+
74
+ it('drops entries older than the window even when the frame is rejected', () => {
75
+ // t=0 has aged out at now=6_100 (5 s window); the two recent entries
76
+ // fill the max=2 budget → rejected, and the decayed entry is gone
77
+ // from the retained window.
78
+ const decision = admitFrameWindow([0, 6_000, 6_050], 6_100, 2, 5_000);
79
+ expect(decision.admitted).toBe(false);
80
+ expect(decision.times).toEqual([6_000, 6_050]);
81
+ });
82
+
83
+ it('does not mutate the caller-supplied times array (pure)', () => {
84
+ const times = [1, 2, 3];
85
+ admitFrameWindow(times, 4, 50, 5_000);
86
+ expect(times).toEqual([1, 2, 3]);
87
+ });
88
+ });
89
+
90
+ describe('frame-flood close constants', () => {
91
+ it('uses RFC 6455 close code 1008 (Policy Violation)', () => {
92
+ expect(FRAME_FLOOD_CLOSE_CODE).toBe(1008);
93
+ });
94
+
95
+ it('delivers the canonical flooding ERROR line before the close', () => {
96
+ expect(FRAME_FLOOD_ERROR_LINE).toBe('ERROR :Closing link (flooding)');
97
+ });
98
+ });
@@ -0,0 +1,60 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { matchOperCred, resetOperLegacyPlaintextWarning } from '../src/commands/oper';
3
+ import { hashAccountCredential } from '../src/credential-hashing';
4
+ import { type OperCredHashed, hashOperCredential } from '../src/oper-hashing';
5
+
6
+ describe('hashOperCredential', () => {
7
+ afterEach(() => {
8
+ resetOperLegacyPlaintextWarning();
9
+ });
10
+
11
+ it('emits a {user, salt, hash} triple with non-empty base64 fields', () => {
12
+ const out = hashOperCredential('admin', 's3cret');
13
+ expect(out.user).toBe('admin');
14
+ expect(out.salt.length).toBeGreaterThan(0);
15
+ expect(out.hash.length).toBeGreaterThan(0);
16
+ // base64 alphabet only (no whitespace, no plaintext leakage).
17
+ expect(out.salt).toMatch(/^[A-Za-z0-9+/]+=*$/u);
18
+ expect(out.hash).toMatch(/^[A-Za-z0-9+/]+=*$/u);
19
+ });
20
+
21
+ it('never stores the plaintext password in any field', () => {
22
+ const out = hashOperCredential('admin', 'hunter2-secret');
23
+ expect(out.user).not.toContain('hunter2');
24
+ expect(out.salt).not.toContain('hunter2');
25
+ expect(out.hash).not.toContain('hunter2');
26
+ });
27
+
28
+ it('produces a fresh random salt per call (no two salts collide)', () => {
29
+ const a = hashOperCredential('admin', 's3cret');
30
+ const b = hashOperCredential('admin', 's3cret');
31
+ expect(a.salt).not.toBe(b.salt);
32
+ expect(a.hash).not.toBe(b.hash);
33
+ });
34
+
35
+ it('honours a fixed salt for deterministic test scenarios', () => {
36
+ const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
37
+ const a = hashOperCredential('admin', 's3cret', { salt });
38
+ const b = hashOperCredential('admin', 's3cret', { salt });
39
+ expect(a.hash).toBe(b.hash);
40
+ });
41
+
42
+ it('round-trips via matchOperCred: the hashed output verifies the original password', () => {
43
+ const cred = hashOperCredential('admin', 's3cret-password');
44
+ expect(matchOperCred([cred], 'admin', 's3cret-password')).toBe(true);
45
+ expect(matchOperCred([cred], 'admin', 'wrong')).toBe(false);
46
+ });
47
+
48
+ it('round-trips via verifyHashedPassword: the underlying scrypt entry is well-formed', () => {
49
+ const cred = hashOperCredential('admin', 's3cret');
50
+ const entry = hashAccountCredential('admin', 's3cret', {
51
+ salt: Uint8Array.from(Buffer.from(cred.salt, 'base64')),
52
+ });
53
+ expect(entry.hash).toBe(cred.hash);
54
+ });
55
+
56
+ it('returns an object assignable to the OperCredHashed type', () => {
57
+ const cred: OperCredHashed = hashOperCredential('admin', 's3cret');
58
+ expect(cred.user).toBe('admin');
59
+ });
60
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unit tests for {@link OperFailureStats} — the runtime-owned per-IP
3
+ * failed-OPER counter (sliding window, AdmissionStats shape).
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import { OperFailureStats } from '../src/oper-lockout';
8
+ import { FakeClock } from '../src/ports';
9
+
10
+ describe('OperFailureStats', () => {
11
+ it('reports zero recent failures for an unknown IP', () => {
12
+ const clock = new FakeClock(5_000);
13
+ const stats = new OperFailureStats(clock);
14
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
15
+ });
16
+
17
+ it('counts recorded failures within the window', () => {
18
+ const clock = new FakeClock(5_000);
19
+ const stats = new OperFailureStats(clock);
20
+ stats.recordFailure('10.0.0.1');
21
+ clock.advance(1_000);
22
+ stats.recordFailure('10.0.0.1');
23
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
24
+ });
25
+
26
+ it('prunes failures older than the window (sliding)', () => {
27
+ const clock = new FakeClock(5_000);
28
+ const stats = new OperFailureStats(clock);
29
+ stats.recordFailure('10.0.0.1'); // t = 5_000
30
+ clock.advance(10_000);
31
+ stats.recordFailure('10.0.0.1'); // t = 15_000
32
+
33
+ // Window that excludes the first failure keeps only the second.
34
+ expect(stats.recentFailures('10.0.0.1', 9_000)).toBe(1);
35
+ // The prune is sticky: the stale entry is gone on the next read.
36
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
37
+ });
38
+
39
+ it('deletes the per-IP entry when every recorded failure is stale', () => {
40
+ const clock = new FakeClock(5_000);
41
+ const stats = new OperFailureStats(clock);
42
+ stats.recordFailure('10.0.0.1');
43
+ clock.advance(301_000);
44
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
45
+ // A later failure for the same IP starts from a fresh entry.
46
+ stats.recordFailure('10.0.0.1');
47
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
48
+ });
49
+
50
+ it('keeps per-IP accounting independent between IPs', () => {
51
+ const clock = new FakeClock(5_000);
52
+ const stats = new OperFailureStats(clock);
53
+ stats.recordFailure('10.0.0.1');
54
+ stats.recordFailure('10.0.0.1');
55
+ expect(stats.recentFailures('10.0.0.2', 300_000)).toBe(0);
56
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
57
+ });
58
+
59
+ it('reset clears every recorded failure for the IP', () => {
60
+ const clock = new FakeClock(5_000);
61
+ const stats = new OperFailureStats(clock);
62
+ stats.recordFailure('10.0.0.1');
63
+ stats.recordFailure('10.0.0.1');
64
+ stats.reset('10.0.0.1');
65
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
66
+ });
67
+
68
+ it('reset is a no-op for an IP with no recorded failures', () => {
69
+ const clock = new FakeClock(5_000);
70
+ const stats = new OperFailureStats(clock);
71
+ expect(() => stats.reset('10.0.0.1')).not.toThrow();
72
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
73
+ });
74
+ });
@@ -1,11 +1,13 @@
1
1
  import fc from 'fast-check';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import type { RawLine } from '../src/effects';
4
+ import { byteLength } from '../src/protocol/bytes';
4
5
  import {
5
6
  MAX_LINE_BYTES,
6
7
  applyServerTime,
7
8
  enforceLineLimit,
8
9
  formatServerTime,
10
+ sanitizeLineText,
9
11
  } from '../src/protocol/outbound';
10
12
  import { parse } from '../src/protocol/parser';
11
13
  import { serialize } from '../src/protocol/serializer';
@@ -243,6 +245,152 @@ describe('enforceLineLimit — WsFrameMode budget', () => {
243
245
  });
244
246
  });
245
247
 
248
+ // ============================================================================
249
+ // enforceLineLimit — byte (not UTF-16 code-unit) accounting
250
+ // ============================================================================
251
+
252
+ describe('enforceLineLimit — multi-byte (UTF-8 byte) accounting', () => {
253
+ const TEXT_BUDGET = MAX_LINE_BYTES - 2;
254
+
255
+ it('truncates a 353 NAMES-style line that fits 510 UTF-16 units but exceeds 510 bytes', () => {
256
+ // Prefix is 23 ASCII bytes; 260 'é' nicks/text = 260 units but 520 bytes.
257
+ // Total: 283 code units (inside the old unit budget), 543 UTF-8 bytes.
258
+ const prefix = ':srv 353 alice = #chan :';
259
+ const line: RawLine = L(`${prefix}${'é'.repeat(260)}`);
260
+ expect(line.text.length).toBeLessThanOrEqual(TEXT_BUDGET);
261
+ expect(byteLength(line.text)).toBeGreaterThan(TEXT_BUDGET);
262
+
263
+ const out = enforceLineLimit(line, new Set<string>());
264
+ expect(out).not.toBe(line);
265
+ expect(out.text.startsWith(prefix)).toBe(true);
266
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
267
+ });
268
+
269
+ it('passes a multi-byte line whose UTF-8 length is exactly 510 bytes through unchanged', () => {
270
+ // "PRIVMSG #c :" (12 ASCII bytes) + 249 'é' (498 bytes) = 510 bytes in
271
+ // 261 code units.
272
+ const line: RawLine = L(`PRIVMSG #c :${'é'.repeat(249)}`);
273
+ expect(line.text.length).toBeLessThan(TEXT_BUDGET);
274
+ expect(byteLength(line.text)).toBe(TEXT_BUDGET);
275
+ expect(enforceLineLimit(line, new Set<string>())).toBe(line);
276
+ });
277
+
278
+ it('keeps the truncated output within the byte budget for a long multi-byte body', () => {
279
+ const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(600)}`);
280
+ const out = enforceLineLimit(line, new Set<string>());
281
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
282
+ expect(out.text.startsWith(':alice PRIVMSG #foo :')).toBe(true);
283
+ });
284
+
285
+ it('never splits a surrogate pair when truncating multi-byte content', () => {
286
+ const prefix = ':alice PRIVMSG #foo :';
287
+ const line: RawLine = L(`${prefix}${'😀'.repeat(400)}`);
288
+ const out = enforceLineLimit(line, new Set<string>());
289
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
290
+ // A split pair would leave a half emoji at the end; the last code point
291
+ // must be a complete emoji.
292
+ expect(out.text.endsWith('😀')).toBe(true);
293
+ });
294
+
295
+ it('preserves the tag section verbatim and truncates the multi-byte body by bytes', () => {
296
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z '; // 31 bytes
297
+ const prefix = ':srv 353 alice = #chan :';
298
+ const line: RawLine = L(`${tagPrefix}${prefix}${'é'.repeat(300)}`);
299
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']));
300
+ expect(out.text.startsWith(`${tagPrefix}${prefix}`)).toBe(true);
301
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
302
+ });
303
+
304
+ it('truncates the multi-byte body by bytes in spec-text mode too', () => {
305
+ const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(400)}`);
306
+ const out = enforceLineLimit(line, new Set<string>(), 'spec-text');
307
+ expect(byteLength(out.text)).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
308
+ });
309
+ });
310
+
311
+ // ============================================================================
312
+ // sanitizeLineText — defense-in-depth CR stripping for RawLine.text
313
+ // ============================================================================
314
+
315
+ describe('sanitizeLineText', () => {
316
+ it('removes a bare CR embedded in the line text', () => {
317
+ // The 311 RPL_WHOISREALNAME shape: a realname carrying a CR would echo
318
+ // the control character verbatim into a client's frame stream.
319
+ const out = sanitizeLineText(L(':srv 311 me alice u h * :real\rname'));
320
+ expect(out.text).toBe(':srv 311 me alice u h * :realname');
321
+ });
322
+
323
+ it('removes every CR in the line text, not just the first', () => {
324
+ const out = sanitizeLineText(L('TOPIC #foo :a\rb\rc'));
325
+ expect(out.text).toBe('TOPIC #foo :abc');
326
+ expect(out.text.includes('\r')).toBe(false);
327
+ });
328
+
329
+ it('returns the same object when the text contains no CR', () => {
330
+ const line: RawLine = L(':alice PRIVMSG #foo :hi');
331
+ expect(sanitizeLineText(line)).toBe(line);
332
+ });
333
+
334
+ it('leaves LF untouched (CR-only hardening)', () => {
335
+ const out = sanitizeLineText(L('PRIVMSG #foo :a\nb'));
336
+ expect(out.text).toBe('PRIVMSG #foo :a\nb');
337
+ });
338
+
339
+ it('does not mutate the input RawLine', () => {
340
+ const original: RawLine = L('PRIVMSG #foo :a\rb');
341
+ sanitizeLineText(original);
342
+ expect(original.text).toBe('PRIVMSG #foo :a\rb');
343
+ });
344
+ });
345
+
346
+ // ============================================================================
347
+ // enforceLineLimit — CR sanitization composes with the byte budget
348
+ // ============================================================================
349
+
350
+ describe('enforceLineLimit — CR sanitization before budget enforcement', () => {
351
+ const TEXT_BUDGET = MAX_LINE_BYTES - 2;
352
+
353
+ it('strips a bare CR from an otherwise in-budget line', () => {
354
+ const line: RawLine = L(':srv 311 me alice u h * :real\rname');
355
+ const out = enforceLineLimit(line, new Set<string>());
356
+ expect(out.text).toBe(':srv 311 me alice u h * :realname');
357
+ });
358
+
359
+ it('strips CR before measuring the budget so a line that fits after stripping is not truncated', () => {
360
+ // 21-byte prefix + 488 'x' + CR + 1 'x' = 511 units/bytes; stripping the
361
+ // single CR byte lands exactly on the 510 budget — no truncation.
362
+ const prefix = ':alice PRIVMSG #foo :';
363
+ const xs = 'x'.repeat(TEXT_BUDGET - prefix.length - 1);
364
+ const line: RawLine = L(`${prefix}${xs}\rx`);
365
+ expect(byteLength(line.text)).toBe(TEXT_BUDGET + 1);
366
+ const out = enforceLineLimit(line, new Set<string>());
367
+ expect(out.text).toBe(`${prefix}${xs}x`);
368
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
369
+ });
370
+
371
+ it('keeps the output within budget when a CR-bearing line also needs truncation', () => {
372
+ const line: RawLine = L(`:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
373
+ const out = enforceLineLimit(line, new Set<string>());
374
+ expect(out.text.includes('\r')).toBe(false);
375
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
376
+ expect(out.text.startsWith(':alice PRIVMSG #foo :ab')).toBe(true);
377
+ });
378
+
379
+ it('preserves the tag section while sanitizing CR from a tagged, over-budget line', () => {
380
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
381
+ const line: RawLine = L(`${tagPrefix}:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
382
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']));
383
+ expect(out.text.startsWith(tagPrefix)).toBe(true);
384
+ expect(out.text.includes('\r')).toBe(false);
385
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
386
+ });
387
+
388
+ it('returns a CR-free line object unchanged (identity preserved for the common case)', () => {
389
+ const line: RawLine = L(':alice PRIVMSG #foo :hi');
390
+ expect(enforceLineLimit(line, new Set<string>())).toBe(line);
391
+ });
392
+ });
393
+
246
394
  // ============================================================================
247
395
  // Round-trip through serialize -> applyServerTime -> parse
248
396
  // ============================================================================