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
@@ -17,6 +17,7 @@ import { cloakHost } from '../cloak.js';
17
17
  import { formatCreatedAt, resolveServerVersion } from '../config.js';
18
18
  import { Effect } from '../effects.js';
19
19
  import type { Effect as EffectType, RawLine } from '../effects.js';
20
+ import { constantTimeEquals } from '../ports.js';
20
21
  import { Numerics } from '../protocol/numerics.js';
21
22
  import type { ChanName } from '../state/channel.js';
22
23
  import { hostmaskOf } from '../state/connection.js';
@@ -28,6 +29,31 @@ import { buildMotdNumerics } from './motd-lines.js';
28
29
  import { enforceRegisteredNick } from './nickserv.js';
29
30
  import { checkAkillAdmission, enforceJupedNick } from './operserv.js';
30
31
 
32
+ /**
33
+ * Indirection over {@link constantTimeEquals} so tests can swap in a
34
+ * call-counting stub and prove the server-password gate funnels every
35
+ * comparison bucket (equal / unequal / different length) through exactly
36
+ * one constant-time comparison. Defaults to the real primitive; `null`
37
+ * restores the default.
38
+ */
39
+ let constantTimeEqualsFn: (a: string, b: string) => boolean = constantTimeEquals;
40
+
41
+ /**
42
+ * Test seam: replaces the server-password comparison primitive with `fn`
43
+ * (or restores the default when `fn === null`). Exported for tests;
44
+ * production callers MUST NOT use.
45
+ *
46
+ * @internal
47
+ */
48
+ export function setServerPasswordCompare(fn: ((a: string, b: string) => boolean) | null): void {
49
+ constantTimeEqualsFn = fn ?? constantTimeEquals;
50
+ }
51
+
52
+ /** @internal test seam: clears any spy registered above. */
53
+ export function resetServerPasswordCompareSpy(): void {
54
+ constantTimeEqualsFn = constantTimeEquals;
55
+ }
56
+
31
57
  /** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
32
58
  const USER_MODES_004 = 'iosw';
33
59
 
@@ -50,6 +76,79 @@ export function isValidNick(nick: string, maxLen: number): boolean {
50
76
  return NICK_GRAMMAR_RE.test(nick);
51
77
  }
52
78
 
79
+ // ============================================================================
80
+ // USER username/realname validation (charset + length + control chars)
81
+ // ----------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Maximum username (ident) length in bytes. Matches the de-facto IRC standard
85
+ * `USERLEN=16` advertised in `005 RPL_ISUPPORT` by mainstream daemons.
86
+ */
87
+ const USERLEN = 16;
88
+
89
+ /**
90
+ * Maximum realname (GECOS) length in bytes. Matches the common ircd-seven
91
+ * GECOS ceiling; enforced as a byte cap so multi-byte UTF-8 realnames are
92
+ * measured correctly.
93
+ */
94
+ const REALLEN = 64;
95
+
96
+ /**
97
+ * C0 control chars + NUL (\x00-\x1f). Covers `\0`, `\r`, `\n`, and the rest
98
+ * of the ASCII control block. These MUST NOT appear in either the username
99
+ * or the realname: a bare `\r` in the realname survives the line parser
100
+ * (which strips only trailing CR/LF) and produces CRLF injection; NUL bytes
101
+ * truncate display; other control chars corrupt terminal rendering.
102
+ */
103
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this regex exists to detect C0 control chars (NUL/CR/LF/etc.) in USER params.
104
+ const CONTROL_CHAR_RE = /[\x00-\x1f]/u;
105
+
106
+ /**
107
+ * Username-specific forbidden chars: space, `!`, and `@`. These corrupt
108
+ * hostmask parsing (`nick!user@host`) — a username containing `!` or `@`
109
+ * makes the hostmask ambiguous, and a space breaks param delimiting on
110
+ * re-serialization. Not applied to realname (spaces are valid there).
111
+ */
112
+ const USERNAME_FORBIDDEN_RE = /[ !@]/u;
113
+
114
+ /** Canonical disconnect reason applied on USER validation failure. */
115
+ const USER_INVALID_REASON = 'invalid USER';
116
+
117
+ /** Tailored NOTICE text emitted before disconnect on an invalid username. */
118
+ const USERNAME_INVALID_TEXT =
119
+ 'Invalid USER: username must be 1-16 bytes with no spaces, !, @, or control characters';
120
+
121
+ /** Tailored NOTICE text emitted before disconnect on an invalid realname. */
122
+ const REALNAME_INVALID_TEXT =
123
+ 'Invalid USER: realname must be at most 64 bytes with no control characters';
124
+
125
+ /** Reused encoder for UTF-8 byte-length measurement (stateless, safe to share). */
126
+ const utf8Encoder = new TextEncoder();
127
+
128
+ /**
129
+ * Returns true iff `value` is a safe username: at most {@link USERLEN} UTF-8
130
+ * bytes, no hostmask-corrupting chars (`!`/`@`/space), and no C0 control
131
+ * chars.
132
+ */
133
+ function isValidUsername(value: string): boolean {
134
+ if (utf8Encoder.encode(value).length > USERLEN) return false;
135
+ if (USERNAME_FORBIDDEN_RE.test(value)) return false;
136
+ if (CONTROL_CHAR_RE.test(value)) return false;
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Returns true iff `value` is a safe realname: at most {@link REALLEN} UTF-8
142
+ * bytes and no C0 control chars. Spaces ARE allowed (real names contain
143
+ * spaces); hostmask chars (`!`/`@`) are also allowed since the realname
144
+ * never appears in a hostmask.
145
+ */
146
+ function isValidRealname(value: string): boolean {
147
+ if (utf8Encoder.encode(value).length > REALLEN) return false;
148
+ if (CONTROL_CHAR_RE.test(value)) return false;
149
+ return true;
150
+ }
151
+
53
152
  /**
54
153
  * Builds the welcome numeric block emitted once registration completes:
55
154
  * `001`–`005` (with `005` generated from the server config) followed by
@@ -100,7 +199,9 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
100
199
  * `serverConfig.serverPassword`) before completing registration:
101
200
  * - No password configured (undefined or empty) → gate skipped.
102
201
  * - SASL already authenticated (`state.account` set) → gate satisfied.
103
- * - Otherwise `state.passAttempt` must equal the configured password.
202
+ * - Otherwise `state.passAttempt` must equal the configured password,
203
+ * compared in constant time ({@link constantTimeEquals}) so response
204
+ * timing leaks neither the password length nor a matching prefix.
104
205
  *
105
206
  * On gate failure the connection is NOT registered; the returned effects
106
207
  * are `[Send(464 ERR_PASSWDMISMATCH), Disconnect("Bad Password")]` per
@@ -187,7 +288,11 @@ function passwordGateFailure(state: ConnectionState, ctx: Ctx): EffectType[] | n
187
288
  if (configured === undefined || configured === '') return null;
188
289
  // SASL success satisfies the password gate.
189
290
  if (state.account !== undefined) return null;
190
- if (state.passAttempt === configured) return null;
291
+ // Constant-time compare: a short-circuiting `===` would leak how much of
292
+ // the attempt matched (and the attempt length) through response timing.
293
+ // A missing attempt folds to the empty string — never equal, because a
294
+ // non-empty `configured` is guaranteed by the disabled-gate check above.
295
+ if (constantTimeEqualsFn(state.passAttempt ?? '', configured)) return null;
191
296
  return [
192
297
  Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_PASSWDMISMATCH, 'Password mismatch')]),
193
298
  Effect.disconnect(ctx.connId, PASSWORD_MISMATCH_REASON),
@@ -212,6 +317,16 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
212
317
  return { text: parts.join(' ') };
213
318
  }
214
319
 
320
+ /**
321
+ * Formats a `:<server> NOTICE <nick> :<text>` line addressed to the
322
+ * connection's current nick (or `*` when unregistered). Used for USER
323
+ * validation rejections where no RFC numeric fits the failure mode.
324
+ */
325
+ function serverNotice(ctx: Ctx, text: string): RawLine {
326
+ const nick = ctx.connection.nick ?? '*';
327
+ return { text: `:${ctx.serverName} NOTICE ${nick} :${text}` };
328
+ }
329
+
215
330
  /**
216
331
  * Reducer for `NICK <nickname>`.
217
332
  *
@@ -330,6 +445,35 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
330
445
  return { state, effects };
331
446
  }
332
447
 
448
+ // Validate username and realname BEFORE storing to avoid half-stored
449
+ // state on rejection. The username (params[0]) flows into every outbound
450
+ // hostmask (`nick!user@host`); a `!`/`@`/space in it corrupts hostmask
451
+ // parsing. The realname (params[3]) appears in `311 RPL_WHOISUSER` and,
452
+ // if it carries a bare `\r`, produces CRLF injection (the line parser
453
+ // strips only trailing CR/LF). Both fields are byte-capped to keep
454
+ // 001/311/005 replies within the 512-byte wire budget.
455
+ //
456
+ // Reply format: a tailored server NOTICE followed by Disconnect.
457
+ // No RFC numeric fits "invalid USER charset" — 461 ERR_NEEDMOREPARAMS is
458
+ // for missing params, and 432 ERR_ERRONEUSNICKNAME is nick-specific. The
459
+ // NOTICE + Disconnect mirrors the AKILL admission hook's pattern and
460
+ // matches mainstream IRC daemon behaviour for registration security
461
+ // failures. The adapter renders the reason as `ERROR :Closing link: …`.
462
+ if (!isValidUsername(user)) {
463
+ effects.push(
464
+ Effect.send(ctx.connId, [serverNotice(ctx, USERNAME_INVALID_TEXT)]),
465
+ Effect.disconnect(ctx.connId, USER_INVALID_REASON),
466
+ );
467
+ return { state, effects };
468
+ }
469
+ if (!isValidRealname(realname)) {
470
+ effects.push(
471
+ Effect.send(ctx.connId, [serverNotice(ctx, REALNAME_INVALID_TEXT)]),
472
+ Effect.disconnect(ctx.connId, USER_INVALID_REASON),
473
+ );
474
+ return { state, effects };
475
+ }
476
+
333
477
  state.user = user;
334
478
  state.realname = realname;
335
479
  if (state.registration === 'pre-registration') {
@@ -6,9 +6,14 @@
6
6
  *
7
7
  * 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is always
8
8
  * supported and answers with `AUTHENTICATE +` (requesting the payload).
9
- * `EXTERNAL` is supported only when an mTLS identity provider is
10
- * configured for the connection; without one, EXTERNAL falls through
11
- * to `908 ERR_SASLMECHS` (mechanism not available) listing what is.
9
+ * `EXTERNAL` is supported only when ALL of no transport-level
10
+ * non-support override (`sasl.externalUnsupportedMessage`), an mTLS
11
+ * identity source, the `sasl.externalEnabled` operator opt-in, and a
12
+ * secure (TLS) transport are in place; under the override the
13
+ * mechanism is rejected up front with a `904 ERR_SASLFAIL` carrying
14
+ * the configured transport-specific message, without it and without
15
+ * any one of the other three, EXTERNAL falls through to
16
+ * `908 ERR_SASLMECHS` (mechanism not available) listing what is.
12
17
  * The verified client-cert subject is resolved at finalise time.
13
18
  * Anything else gets `908 ERR_SASLMECHS` listing what is available.
14
19
  * 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
@@ -17,18 +22,28 @@
17
22
  * on success the connection records the account name and the reducer
18
23
  * emits `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
19
24
  * `904 ERR_SASLFAIL`. PLAIN consults `services.verifyNick`, EXTERNAL
20
- * consults `services.verifyCertFP` against the mTLS-resolved subject.
25
+ * consults `services.verifyCertFP` with the mTLS-resolved cert
26
+ * identity (DER SHA-256 fingerprint preferred, canonical DN
27
+ * fallback).
21
28
  * 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
22
29
  *
23
30
  * The {@link ServicesStore} port is **synchronous** (reducers stay pure):
24
31
  * adapters pre-load credentials so the reducer stays pure. Async backends
25
32
  * resolve their lookups before the actor invokes this reducer.
26
33
  *
34
+ * Brute-force hardening: every `904 ERR_SASLFAIL` records a timestamp on
35
+ * the per-connection tracker ({@link ConnectionState.saslFailures}); once
36
+ * the configured `sasl.maxFailures` ceiling is reached inside the
37
+ * configured `sasl.failureWindowSeconds` sliding window, the connection is
38
+ * disconnected (904 first, then the Disconnect). A successful login resets
39
+ * the tracker.
40
+ *
27
41
  * State: {@link ConnectionState.saslMech} tracks the mechanism in flight;
28
42
  * {@link ConnectionState.saslBuffer} accumulates chunked payloads. Both are
29
43
  * cleared on every terminal outcome.
30
44
  */
31
45
 
46
+ import { DEFAULT_SASL_FAILURE_WINDOW_SECONDS, DEFAULT_SASL_MAX_FAILURES } from '../config.js';
32
47
  import { Effect } from '../effects.js';
33
48
  import type { Effect as EffectType, RawLine } from '../effects.js';
34
49
  import type { ServicesAuthResult } from '../ports.js';
@@ -44,16 +59,51 @@ const SASL_CHUNK_BYTES = 400;
44
59
  /** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
45
60
  const SASL_MAX_BYTES = 8192;
46
61
 
62
+ /**
63
+ * Disconnect reason stamped once the per-connection SASL failure lockout
64
+ * trips. The adapter renders it as `ERROR :Closing link: (reason)` — the
65
+ * same tailoring convention as the registration `invalid USER` path.
66
+ */
67
+ const SASL_LOCKOUT_REASON = 'too many failed SASL attempts';
68
+
47
69
  /**
48
70
  * Mechanisms advertised in `908 ERR_SASLMECHS` and the `sasl` cap value,
49
- * derived from whether mTLS is configured for this connection.
71
+ * derived from whether SASL EXTERNAL is available for this connection.
72
+ *
73
+ * PLAIN is always available. EXTERNAL requires ALL of:
74
+ * 1. no transport-level non-support override
75
+ * (`sasl.externalUnsupportedMessage` unset — the seam a transport
76
+ * without any mTLS identity source uses to pin the mechanism off),
77
+ * 2. an mTLS identity source bound for the connection (edge mTLS
78
+ * configured — CF API Shield / AWS APIGW client certs),
79
+ * 3. the operator opt-in (`sasl.externalEnabled`, surfaced by the
80
+ * adapters as `EXTERNAL_ENABLED`; default off),
81
+ * 4. a secure (TLS) transport — the client-cert identity only carries
82
+ * meaning over a connection the server trusts.
50
83
  *
51
- * PLAIN is always available; EXTERNAL only when an `MtlsIdentityProvider`
52
- * is bound. Operators enable EXTERNAL by configuring mTLS at the edge
53
- * no code change required.
84
+ * Without any one of these the mechanism is neither advertised nor
85
+ * accepted: clients see `908 ERR_SASLMECHS` listing `PLAIN` only
86
+ * except under the override, which rejects `AUTHENTICATE EXTERNAL`
87
+ * outright with the configured `904 ERR_SASLFAIL` message.
54
88
  */
55
89
  function supportedMechs(ctx: Ctx): string {
56
- return ctx.mtlsIdentity !== undefined ? 'PLAIN,EXTERNAL' : 'PLAIN';
90
+ return externalAvailable(ctx) ? 'PLAIN,EXTERNAL' : 'PLAIN';
91
+ }
92
+
93
+ /**
94
+ * Whether SASL EXTERNAL may run on this connection: no transport-level
95
+ * non-support override AND mTLS identity source bound AND the operator
96
+ * opted in AND the transport is secure. The `sasl` cap value (see
97
+ * `capabilities.ts`) applies the same gate so advertisement and
98
+ * enforcement cannot drift apart.
99
+ */
100
+ function externalAvailable(ctx: Ctx): boolean {
101
+ return (
102
+ ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
103
+ ctx.mtlsIdentity !== undefined &&
104
+ (ctx.serverConfig.sasl?.externalEnabled ?? false) &&
105
+ ctx.connection.secure
106
+ );
57
107
  }
58
108
 
59
109
  /**
@@ -108,7 +158,20 @@ function handleMechanism(
108
158
  return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
109
159
  }
110
160
  if (mech === 'EXTERNAL') {
111
- if (ctx.mtlsIdentity === undefined) {
161
+ // Transport-level non-support override first: a transport that
162
+ // cannot surface a client-cert identity rejects up front with the
163
+ // configured stable `904` message — never the generic `908` — even
164
+ // when every three-way-gate input is bound. A protocol-level
165
+ // capability refusal, not a credential attempt: it does not count
166
+ // toward the failure lockout.
167
+ const unsupportedMessage = ctx.serverConfig.sasl?.externalUnsupportedMessage;
168
+ if (unsupportedMessage !== undefined) {
169
+ return {
170
+ state,
171
+ effects: [Effect.send(ctx.connId, [saslExternalUnsupported(ctx, unsupportedMessage)])],
172
+ };
173
+ }
174
+ if (!externalAvailable(ctx)) {
112
175
  return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
113
176
  }
114
177
  state.saslMech = 'EXTERNAL';
@@ -168,7 +231,7 @@ function finalizePlain(
168
231
 
169
232
  if (parsed === null) {
170
233
  clearSasl(state);
171
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
234
+ return { state, effects: saslFailEffects(state, ctx) };
172
235
  }
173
236
 
174
237
  // Single credential home: services.verifyNick covers every NickServ
@@ -178,13 +241,13 @@ function finalizePlain(
178
241
  const services = ctx.services;
179
242
  if (services === undefined) {
180
243
  clearSasl(state);
181
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
244
+ return { state, effects: saslFailEffects(state, ctx) };
182
245
  }
183
246
  const result = services.verifyNick(parsed.username, parsed.password);
184
247
  return applyResult(state, result, ctx);
185
248
  }
186
249
 
187
- /** SASL EXTERNAL: resolve the mTLS cert subject and verify it. */
250
+ /** SASL EXTERNAL: resolve the mTLS cert identity and verify it. */
188
251
  function finalizeExternal(
189
252
  state: ConnectionState,
190
253
  ctx: Ctx,
@@ -194,16 +257,17 @@ function finalizeExternal(
194
257
 
195
258
  if (identity === undefined) {
196
259
  clearSasl(state);
197
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
260
+ return { state, effects: saslFailEffects(state, ctx) };
198
261
  }
199
262
 
200
- // Cert subjects are mapped to accounts via services.verifyCertFP — the
201
- // same single credential home as SASL PLAIN / PASS-auth. No separate
202
- // services lookup.
263
+ // Cert identities (fingerprint-preferred, canonical-DN fallback) are
264
+ // mapped to accounts via services.verifyCertFP the same single
265
+ // credential home as SASL PLAIN / PASS-auth. No separate services
266
+ // lookup.
203
267
  const services = ctx.services;
204
268
  if (services === undefined) {
205
269
  clearSasl(state);
206
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
270
+ return { state, effects: saslFailEffects(state, ctx) };
207
271
  }
208
272
  const result = services.verifyCertFP(identity);
209
273
  return applyResult(state, result, ctx);
@@ -218,9 +282,53 @@ function applyResult(
218
282
  clearSasl(state);
219
283
 
220
284
  if (result.ok) {
285
+ // A successful login forgives this connection's prior SASL failures:
286
+ // the holder of the credential is legitimate. (Further AUTHENTICATE
287
+ // frames are rejected with 907 anyway, but the tracker is cleared so
288
+ // persisted/live state never carries a stale count.)
289
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
290
+ delete state.saslFailures;
221
291
  return { state, effects: applyAccountSuccess(state, ctx, result.account) };
222
292
  }
223
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
293
+ return { state, effects: saslFailEffects(state, ctx) };
294
+ }
295
+
296
+ /**
297
+ * Records one failed SASL attempt on the per-connection tracker and builds
298
+ * the failure effects: the `904 ERR_SASLFAIL` always, followed by a
299
+ * `Disconnect` once the configured threshold is crossed inside the
300
+ * configured sliding window (the 904 is delivered first so the client sees
301
+ * why it is being closed).
302
+ *
303
+ * What counts as a failed attempt: every outcome that emits 904 — a wrong
304
+ * password / unknown user (PLAIN verify), an unverified cert subject or
305
+ * missing client cert (EXTERNAL verify), an undecodable base64 payload,
306
+ * and a finalize with no ServicesStore bound. Protocol-level rejections
307
+ * that are not credential attempts do NOT count (`905 ERR_SASLTOOLONG`,
308
+ * `908 ERR_SASLMECHS`), and a client abort (`AUTHENTICATE *`) neither
309
+ * counts nor resets the tracker — an attacker must not launder failures
310
+ * away with an abort between guesses.
311
+ *
312
+ * Sliding window: timestamps older than the window (measured via the
313
+ * injected `Clock`, never wall time) are pruned before the new failure is
314
+ * appended, so only failures inside the window are compared against
315
+ * `sasl.maxFailures`.
316
+ */
317
+ function saslFailEffects(state: ConnectionState, ctx: Ctx): EffectType[] {
318
+ const maxFailures = ctx.serverConfig.sasl?.maxFailures ?? DEFAULT_SASL_MAX_FAILURES;
319
+ const windowMs =
320
+ (ctx.serverConfig.sasl?.failureWindowSeconds ?? DEFAULT_SASL_FAILURE_WINDOW_SECONDS) * 1_000;
321
+ const now = ctx.clock.now();
322
+ const failures = (state.saslFailures ?? []).filter((t) => t > now - windowMs);
323
+ failures.push(now);
324
+ state.saslFailures = failures;
325
+ if (failures.length >= maxFailures) {
326
+ return [
327
+ Effect.send(ctx.connId, [saslFail(ctx)]),
328
+ Effect.disconnect(ctx.connId, SASL_LOCKOUT_REASON),
329
+ ];
330
+ }
331
+ return [Effect.send(ctx.connId, [saslFail(ctx)])];
224
332
  }
225
333
 
226
334
  /**
@@ -254,6 +362,15 @@ function saslFail(ctx: Ctx): RawLine {
254
362
  );
255
363
  }
256
364
 
365
+ /**
366
+ * Transport-level EXTERNAL non-support refusal: the configured message
367
+ * verbatim (`sasl.externalUnsupportedMessage`), formatted like every
368
+ * other `904` so clients render it as a terminal SASL failure.
369
+ */
370
+ function saslExternalUnsupported(ctx: Ctx, message: string): RawLine {
371
+ return L(`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :${message}`);
372
+ }
373
+
257
374
  function saslTooLong(ctx: Ctx): RawLine {
258
375
  return L(
259
376
  `:${ctx.serverName} ${code(Numerics.ERR_SASLTOOLONG)} ${nickOf(ctx)} :SASL message too long`,
@@ -13,28 +13,23 @@
13
13
  * - Sets `state.topic` (clearing it for an empty trailing param).
14
14
  * - Honors `+t` (topic lock): only ops may change the topic; non-op
15
15
  * members get `482 ERR_CHANOPRIVSNEEDED`.
16
- * - Truncates the topic to `serverConfig.topicLen` to keep wire lines
17
- * within the 512-byte budget.
16
+ * - Truncates the topic to `serverConfig.topicLen` UTF-8 bytes (the
17
+ * byte ceiling advertised as `TOPICLEN`) to keep wire lines within
18
+ * the 512-byte budget.
18
19
  * - Broadcasts `:hostmask TOPIC <chan> :<topic>` to the channel.
19
20
  */
20
21
 
21
22
  import { Effect } from '../effects.js';
22
23
  import type { Effect as EffectType, RawLine } from '../effects.js';
23
24
  import type { PersistedTopic } from '../ports.js';
25
+ import { byteLength, truncateToByteBudget } from '../protocol/bytes.js';
26
+ import { isValidChannelName } from '../protocol/channel-name.js';
24
27
  import { Numerics } from '../protocol/numerics.js';
25
28
  import type { ChannelState, ChannelTopic } from '../state/channel.js';
26
29
  import { hostmaskOf } from '../state/connection.js';
27
30
  import type { ConnectionState } from '../state/connection.js';
28
31
  import type { Ctx, Reducer } from '../types.js';
29
32
 
30
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
31
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
32
-
33
- function isValidChannelName(name: string, maxLen: number): boolean {
34
- if (name.length === 0 || name.length > maxLen) return false;
35
- return CHANNEL_NAME_RE.test(name);
36
- }
37
-
38
33
  /** Formats a numeric error/reply line addressed to the connection's nick. */
39
34
  function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
40
35
  const nick = ctx.connection.nick ?? '*';
@@ -107,9 +102,12 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
107
102
  return { state, effects };
108
103
  }
109
104
 
105
+ // TOPICLEN is a UTF-8 byte ceiling (as advertised via ISUPPORT): measure
106
+ // bytes, not UTF-16 code units, and shrink to a complete-char boundary so
107
+ // a CJK/emoji topic is never split mid-character.
110
108
  const truncated =
111
- newTopic.length > ctx.serverConfig.topicLen
112
- ? newTopic.slice(0, ctx.serverConfig.topicLen)
109
+ byteLength(newTopic) > ctx.serverConfig.topicLen
110
+ ? truncateToByteBudget(newTopic, ctx.serverConfig.topicLen)
113
111
  : newTopic;
114
112
 
115
113
  // Empty topic clears; non-empty sets.
@@ -33,19 +33,12 @@
33
33
 
34
34
  import { Effect } from '../effects.js';
35
35
  import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { isValidChannelName } from '../protocol/channel-name.js';
36
37
  import { Numerics } from '../protocol/numerics.js';
37
38
  import type { ChannelState, RosterEntry } from '../state/channel.js';
38
39
  import type { ConnectionState } from '../state/connection.js';
39
40
  import type { Ctx } from '../types.js';
40
41
 
41
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
42
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
43
-
44
- function isValidChannelName(name: string, maxLen: number): boolean {
45
- if (name.length === 0 || name.length > maxLen) return false;
46
- return CHANNEL_NAME_RE.test(name);
47
- }
48
-
49
42
  /** Formats a numeric error/reply line addressed to the connection's nick. */
50
43
  function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
51
44
  const nick = ctx.connection.nick ?? '*';