serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,65 @@
1
+ /**
2
+ * UTF-8 byte accounting for IRC wire budgets.
3
+ *
4
+ * IRC line limits are byte budgets (RFC 1459 §2.3: 512 bytes including the
5
+ * trailing CR-LF; IRCv3 WebSocket framing: 510 content bytes). JavaScript
6
+ * strings expose UTF-16 code units via `.length`, so a 511-character frame
7
+ * of `é` measures 511 units but occupies 1022 bytes on the wire. Every
8
+ * line-budget seam in this package measures through {@link byteLength} so
9
+ * the cap means bytes.
10
+ */
11
+
12
+ /** Shared `TextEncoder` (module-level singleton, reused — never per call). */
13
+ const TEXT_ENCODER = new TextEncoder();
14
+
15
+ /**
16
+ * Maximum size, in UTF-8 bytes, of a TCP byte-stream transport's retained
17
+ * partial-line buffer (the `transportBuffer` carried between chunks).
18
+ *
19
+ * The IRC line limit itself is 512 bytes (RFC 1459 §2.3), enforced by the
20
+ * parser AFTER line splitting — but that cap cannot fire on a peer that
21
+ * streams megabytes with no line terminator, which would grow the framing
22
+ * buffer unboundedly before any line exists to measure. This transport-level
23
+ * cap bounds that memory: well above a single legal line, with room for
24
+ * multi-line batching, small enough to make per-peer buffer abuse a
25
+ * non-amplifying nuisance. Shared by every raw TCP transport edge
26
+ * (irc-server's `TcpByteStreamTransport`, local-cli's `LineScanner`, the
27
+ * AWS NLB stream handler, and the Cloudflare TCP container origin) so all
28
+ * four enforce one identical budget.
29
+ */
30
+ export const MAX_BUFFER_BYTES = 8 * 1024;
31
+
32
+ /**
33
+ * Returns the UTF-8 byte length of `s` — the size the wire transport
34
+ * actually sends and the quantity every IRC line budget is defined in.
35
+ *
36
+ * Cost note: this runs on the data-plane hot path for every inbound and
37
+ * outbound line. The module-level singleton `TextEncoder` above avoids
38
+ * per-call allocation, which is the dominant cost. Documented follow-up
39
+ * (profiling-gated, NOT implemented): an ASCII fast path
40
+ * (`s.length === byteLength(s)` style elision) to skip the encode for
41
+ * all-ASCII lines, which is the common case.
42
+ */
43
+ export function byteLength(s: string): number {
44
+ return TEXT_ENCODER.encode(s).length;
45
+ }
46
+
47
+ /**
48
+ * Right-truncates `s` to at most `budget` UTF-8 bytes, never splitting a
49
+ * surrogate pair: code points are appended while they fit, so an astral
50
+ * character near the boundary is dropped whole and the result may land a
51
+ * few bytes under `budget`. For all-ASCII input the result is identical to
52
+ * `s.slice(0, budget)`. Only called on the (rare) over-budget path.
53
+ */
54
+ export function truncateToByteBudget(s: string, budget: number): string {
55
+ if (budget <= 0) return '';
56
+ let used = 0;
57
+ let out = '';
58
+ for (const ch of s) {
59
+ const size = byteLength(ch);
60
+ if (used + size > budget) break;
61
+ used += size;
62
+ out += ch;
63
+ }
64
+ return out;
65
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Channel-name grammar and validation — the single source of truth shared
3
+ * by every command reducer that validates or routes on a channel target
4
+ * (JOIN, PART, TOPIC, NAMES, MODE, KICK, INVITE, WHO, LIST, ChanServ, and
5
+ * PRIVMSG/NOTICE target routing).
6
+ *
7
+ * Grammar: the first character must be a channel prefix (`#` or `&`); the
8
+ * rest may be any character except:
9
+ * - NUL and the C0 control range `\x00-\x1f` — RFC 1459 §1.3.3 forbids
10
+ * NUL, CR, and LF in channel names; a control byte embedded in a name
11
+ * is concatenated raw into outbound broadcasts and 353 numerics,
12
+ * truncating display in C-based clients and confusing naive parsers,
13
+ * - whitespace (`\s`),
14
+ * - comma (the multi-target list separator),
15
+ * - colon (the trailing-parameter marker).
16
+ *
17
+ * The length cap is measured in UTF-8 bytes — the unit the wire budget is
18
+ * defined in — and includes the `#`/`&` prefix, matching how the name
19
+ * occupies the line. RFC 2811 §1.3 suggests limiting channel names to 50
20
+ * bytes; the operative cap is `serverConfig.channelLen` (advertised as
21
+ * `CHANNELLEN=`, default 50).
22
+ */
23
+
24
+ import { byteLength } from './bytes.js';
25
+
26
+ /** Channel-name grammar; see module docs for the rejected classes. */
27
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this regex exists to reject NUL and C0 control chars (RFC 1459 §1.3.3) in channel names.
28
+ export const CHANNEL_NAME_RE = /^[#&][^\s,\x00-\x1f:]+$/u;
29
+
30
+ /**
31
+ * Returns true iff `name` matches the channel-name grammar and its UTF-8
32
+ * byte length (prefix included) is at most `maxLenBytes`.
33
+ */
34
+ export function isValidChannelName(name: string, maxLenBytes: number): boolean {
35
+ if (name.length === 0 || byteLength(name) > maxLenBytes) return false;
36
+ return CHANNEL_NAME_RE.test(name);
37
+ }
@@ -1,5 +1,15 @@
1
1
  export type { IrcMessage, IrcSource } from './messages.js';
2
- export { MAX_INPUT_BYTES, IrcParseError, parse, unescapeTagValue } from './parser.js';
2
+ export { MAX_BUFFER_BYTES, byteLength, truncateToByteBudget } from './bytes.js';
3
+ export { CHANNEL_NAME_RE, isValidChannelName } from './channel-name.js';
4
+ export {
5
+ MAX_INPUT_BYTES,
6
+ MAX_TAG_BYTES,
7
+ MAX_TAGS,
8
+ IrcParseError,
9
+ parse,
10
+ parseTags,
11
+ unescapeTagValue,
12
+ } from './parser.js';
3
13
  export { escapeTagValue, serialize, serializeFrame } from './serializer.js';
4
14
  export {
5
15
  MAX_LINE_BYTES,
@@ -7,6 +17,7 @@ export {
7
17
  enforceLineLimit,
8
18
  filterClientTags,
9
19
  formatServerTime,
20
+ sanitizeLineText,
10
21
  } from './outbound.js';
11
22
  export { wrapBatch, type BatchFrame } from './batch.js';
12
23
  export {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { RawLine } from '../effects.js';
19
19
  import { MAX_WS_MESSAGE_BYTES, type WsFrameMode } from '../ws-framing.js';
20
+ import { byteLength, truncateToByteBudget } from './bytes.js';
20
21
 
21
22
  /**
22
23
  * RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
@@ -65,9 +66,35 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
65
66
  return { text: `@time=${stamp} ${line.text}` };
66
67
  }
67
68
 
69
+ /**
70
+ * Strips every carriage return from `line.text`.
71
+ *
72
+ * Defense-in-depth for paths that construct an outbound line from stored
73
+ * state (TOPIC text, realname in 311) which may predate inbound bare-CR
74
+ * rejection: {@link RawLine} text must never carry CR, because the
75
+ * transport layer appends its own CRLF framing per line, so an embedded CR
76
+ * would corrupt framing on RFC-compliant clients.
77
+ *
78
+ * CR is REMOVED rather than replaced with a space so stripping only ever
79
+ * shrinks the line — every downstream byte budget still holds. LF is left
80
+ * untouched (the hardening is deliberately CR-only).
81
+ *
82
+ * The input {@link RawLine} is never mutated; the original object is
83
+ * returned unchanged when the text contains no CR.
84
+ */
85
+ export function sanitizeLineText(line: RawLine): RawLine {
86
+ if (!line.text.includes('\r')) return line;
87
+ return { text: line.text.replace(/\r/gu, '') };
88
+ }
89
+
68
90
  /**
69
91
  * Enforces the IRC line-length budget on `line.text`.
70
92
  *
93
+ * CR sanitization ({@link sanitizeLineText}) runs FIRST, so the budget is
94
+ * measured on the sanitized text: a line that fits after CR removal is not
95
+ * truncated, and a line that still exceeds the budget is truncated from
96
+ * already-sanitized text.
97
+ *
71
98
  * For recipients that negotiated `message-tags`, the tag section (the
72
99
  * leading `@... ` prefix) is part of the wire budget and is preserved
73
100
  * intact; truncation happens at the end of the trailing parameter only.
@@ -83,36 +110,42 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
83
110
  *
84
111
  * The returned {@link RawLine} is the original object when no truncation
85
112
  * was needed, otherwise a fresh object with the truncated text.
113
+ *
114
+ * The budget is measured in UTF-8 bytes (the wire encoding), not UTF-16
115
+ * code units: a line of multi-byte characters that fits the unit count can
116
+ * still exceed the byte ceiling, and truncation re-encodes to the byte
117
+ * budget so the emitted text always fits it.
86
118
  */
87
119
  export function enforceLineLimit(
88
120
  line: RawLine,
89
121
  caps: ReadonlySet<string>,
90
122
  mode?: WsFrameMode,
91
123
  ): RawLine {
124
+ const sanitized = sanitizeLineText(line);
92
125
  const budget = isSpecMode(mode) ? MAX_WS_MESSAGE_BYTES : MAX_LINE_BYTES - TRAILING_CRLF_LEN;
93
- if (line.text.length <= budget) return line;
126
+ if (byteLength(sanitized.text) <= budget) return sanitized;
94
127
 
95
- const hasTags = caps.has('message-tags') && line.text.startsWith('@');
128
+ const hasTags = caps.has('message-tags') && sanitized.text.startsWith('@');
96
129
  if (!hasTags) {
97
- return { text: line.text.slice(0, budget) };
130
+ return { text: truncateToByteBudget(sanitized.text, budget) };
98
131
  }
99
132
 
100
133
  // Preserve the tag section verbatim; truncate only the post-tag body.
101
- const spaceIdx = line.text.indexOf(' ');
134
+ const spaceIdx = sanitized.text.indexOf(' ');
102
135
  if (spaceIdx === -1) {
103
136
  // Malformed: a `@`-prefixed line with no space. Truncate from the right.
104
- return { text: line.text.slice(0, budget) };
137
+ return { text: truncateToByteBudget(sanitized.text, budget) };
105
138
  }
106
- const tagSection = line.text.slice(0, spaceIdx + 1); // include trailing space
107
- const body = line.text.slice(spaceIdx + 1);
108
- const bodyBudget = budget - tagSection.length;
139
+ const tagSection = sanitized.text.slice(0, spaceIdx + 1); // include trailing space
140
+ const body = sanitized.text.slice(spaceIdx + 1);
141
+ const bodyBudget = budget - byteLength(tagSection);
109
142
  if (bodyBudget < 0) {
110
143
  // Pathological: the tag section alone exceeds the budget. Drop the body
111
144
  // entirely and truncate the tag section itself so the wire contract
112
145
  // (≤ budget bytes) still holds.
113
- return { text: line.text.slice(0, budget) };
146
+ return { text: truncateToByteBudget(sanitized.text, budget) };
114
147
  }
115
- return { text: `${tagSection}${body.slice(0, bodyBudget)}` };
148
+ return { text: `${tagSection}${truncateToByteBudget(body, bodyBudget)}` };
116
149
  }
117
150
 
118
151
  /**
@@ -1,3 +1,4 @@
1
+ import { byteLength } from './bytes.js';
1
2
  import type { IrcMessage, IrcSource } from './messages.js';
2
3
 
3
4
  /**
@@ -29,6 +30,27 @@ export class IrcParseError extends Error {
29
30
  */
30
31
  export const MAX_INPUT_BYTES = 512;
31
32
 
33
+ /**
34
+ * IRCv3 Message Tags spec: recommended maximum number of tags on a single
35
+ * message. Every tag is unescaped and (when `message-tags` is negotiated)
36
+ * re-serialized onto downstream lines, so tag count is an amplification
37
+ * lever; a client packing dozens of short tags per line is rejected as a
38
+ * protocol violation rather than parsed.
39
+ */
40
+ export const MAX_TAGS = 15;
41
+
42
+ /**
43
+ * IRCv3 Message Tags spec: recommended maximum size, in UTF-8 bytes, of a
44
+ * message's tag section — the tag data between the leading `@` and the
45
+ * space that terminates the section (both excluded from the measurement).
46
+ * Measured in bytes (not UTF-16 code units) via {@link byteLength} so
47
+ * multi-byte tag content cannot double the effective budget. Defense in
48
+ * depth: the RFC 1459 {@link MAX_INPUT_BYTES} line cap already bounds any
49
+ * section that reaches {@link parse}; this guard holds the spec budget for
50
+ * callers of {@link parseTags} independent of the line cap.
51
+ */
52
+ export const MAX_TAG_BYTES = 8192;
53
+
32
54
  const TAG_ESCAPES: Readonly<Record<string, string>> = {
33
55
  ':': ';',
34
56
  s: ' ',
@@ -58,15 +80,46 @@ export function unescapeTagValue(value: string): string {
58
80
  return out;
59
81
  }
60
82
 
61
- function parseTags(section: string): Record<string, string> {
62
- const tags: Record<string, string> = {};
83
+ /**
84
+ * Parses the tag section between the leading `@` and its terminating space
85
+ * into a null-prototype tag map. Exported for direct validation at this
86
+ * seam: the byte budget is unreachable through {@link parse} (the 512-byte
87
+ * line cap fires first), so the tag-section guards are unit-tested here.
88
+ */
89
+ export function parseTags(section: string, line: string): Record<string, string> {
90
+ // Null prototype: tag keys are attacker-controlled, so a plain object
91
+ // literal would route magic keys (`__proto__`, `constructor`, `toString`,
92
+ // `hasOwnProperty`, …) through `Object.prototype` accessors instead of
93
+ // creating own properties — `__proto__=x` would be silently dropped by
94
+ // the setter, and the others would shadow inherited methods.
95
+ const tags: Record<string, string> = Object.create(null);
96
+ const sectionBytes = byteLength(section);
97
+ if (sectionBytes > MAX_TAG_BYTES) {
98
+ throw new IrcParseError(
99
+ `tag section exceeds ${MAX_TAG_BYTES}-byte budget (${sectionBytes} bytes)`,
100
+ line,
101
+ );
102
+ }
103
+ let tagCount = 0;
63
104
  for (const entry of section.split(';')) {
64
105
  if (entry === '') continue;
106
+ tagCount++;
107
+ if (tagCount > MAX_TAGS) {
108
+ throw new IrcParseError(`too many tags (${tagCount} > ${MAX_TAGS})`, line);
109
+ }
65
110
  const eq = entry.indexOf('=');
66
111
  if (eq === -1) {
67
112
  tags[entry] = '';
68
113
  } else {
69
- tags[entry.slice(0, eq)] = unescapeTagValue(entry.slice(eq + 1));
114
+ const value = unescapeTagValue(entry.slice(eq + 1));
115
+ // IRCv3 Message Tags: CR, LF and NUL MUST NOT appear in tag values —
116
+ // not even via their `\r` / `\n` escape sequences. The line-level
117
+ // bare-CR guard cannot see these: the wire bytes are backslash + 'r',
118
+ // not CR, so the check fires on the UNESCAPED value.
119
+ if (/[\r\n\0]/u.test(value)) {
120
+ throw new IrcParseError('tag value contains forbidden CR/LF/NUL', line);
121
+ }
122
+ tags[entry.slice(0, eq)] = value;
70
123
  }
71
124
  }
72
125
  return tags;
@@ -105,7 +158,9 @@ const COMMAND_RE = /^(?:[A-Za-z]+|[0-9]{3})$/;
105
158
  *
106
159
  * Lenient where real-world clients are: tolerates missing CR/LF and collapses
107
160
  * accidental runs of spaces between tokens. Strict where the grammar is: a
108
- * command token is required and must be all-letters or exactly three digits.
161
+ * command token is required and must be all-letters or exactly three digits,
162
+ * and a bare CR (any CR left after the trailing terminator strip) is a
163
+ * protocol violation.
109
164
  */
110
165
  export function parse(line: string): IrcMessage {
111
166
  // Strip any trailing CR/LF (frame tolerance for ws text frames or TCP lines).
@@ -114,13 +169,27 @@ export function parse(line: string): IrcMessage {
114
169
  throw new IrcParseError('empty message', line);
115
170
  }
116
171
 
172
+ // After the trailing terminator strip, any remaining CR is bare by
173
+ // definition: a mid-line CR-LF would have split the frame at the
174
+ // transport layer, so a surviving CR means frame control characters are
175
+ // being smuggled inside message content (middle/trailing params, tag
176
+ // sections, source prefixes). Reject — such content would otherwise be
177
+ // stored and echoed verbatim into outbound lines, corrupting framing on
178
+ // RFC-compliant clients.
179
+ if (stripped.includes('\r')) {
180
+ throw new IrcParseError('bare CR in message', line);
181
+ }
182
+
117
183
  // RFC 1459 §2.3 hard cap: reject any single message whose post-CRLF body
118
- // exceeds {@link MAX_INPUT_BYTES} bytes. Prevents memory and processing
119
- // abuse from over-long inputs and matches the symmetric cap enforced on
120
- // outbound lines by `outbound.ts`.
121
- if (stripped.length > MAX_INPUT_BYTES) {
184
+ // exceeds {@link MAX_INPUT_BYTES} bytes. Measured in UTF-8 bytes (the wire
185
+ // encoding), not UTF-16 code units, so multi-byte content cannot double
186
+ // the effective cap. Prevents memory and processing abuse from over-long
187
+ // inputs and matches the symmetric cap enforced on outbound lines by
188
+ // `outbound.ts`.
189
+ const inputBytes = byteLength(stripped);
190
+ if (inputBytes > MAX_INPUT_BYTES) {
122
191
  throw new IrcParseError(
123
- `message exceeds ${MAX_INPUT_BYTES}-byte input cap (${stripped.length} bytes)`,
192
+ `message exceeds ${MAX_INPUT_BYTES}-byte input cap (${inputBytes} bytes)`,
124
193
  line,
125
194
  );
126
195
  }
@@ -133,7 +202,7 @@ export function parse(line: string): IrcMessage {
133
202
  if (space === -1) {
134
203
  throw new IrcParseError('tag section with no message body', line);
135
204
  }
136
- tags = parseTags(rest.slice(1, space));
205
+ tags = parseTags(rest.slice(1, space), line);
137
206
  rest = rest.slice(space + 1).trimStart();
138
207
  }
139
208
 
@@ -93,6 +93,19 @@ export interface ConnectionState {
93
93
  * and consumed once the final short chunk (or `+`) arrives.
94
94
  */
95
95
  saslBuffer?: string;
96
+ /**
97
+ * Epoch-ms timestamps of failed SASL attempts on this connection (one
98
+ * entry per `904 ERR_SASLFAIL`), pruned to the configured sliding window
99
+ * on every recording. Once the array reaches the configured
100
+ * `sasl.maxFailures` ceiling within the window, the SASL reducer
101
+ * disconnects the connection (online brute-force hardening).
102
+ *
103
+ * Pre-registration scratch: SASL only runs before registration
104
+ * completes, and adapters do not persist pre-registration scratch fields
105
+ * (`saslMech`/`saslBuffer` are likewise absent from the persisted
106
+ * shape), so a reconnect starts with a clean tracker by construction.
107
+ */
108
+ saslFailures?: number[];
96
109
  registration: RegistrationState;
97
110
  /**
98
111
  * True once the client has sent `CAP LS` or `CAP REQ` and not yet sent