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
@@ -35,6 +35,7 @@ import {
35
35
  type ConnectionState,
36
36
  DEFAULT_MONITOR_LIMIT,
37
37
  DEFAULT_MULTILINE_MAX_BYTES,
38
+ DEFAULT_MULTILINE_MAX_ENTRIES,
38
39
  Effect,
39
40
  type Effect as EffectType,
40
41
  EmptyMotdProvider,
@@ -54,6 +55,7 @@ import {
54
55
  NoopLoggerInstance,
55
56
  Numerics,
56
57
  OPERSERV_NICK,
58
+ type OperFailureStats,
57
59
  type RawLine,
58
60
  type Reducer,
59
61
  SHORTCUT_TO_SERVICE,
@@ -63,6 +65,7 @@ import {
63
65
  applyLabelToEffects,
64
66
  buildCtx,
65
67
  buildMultilineFanout,
68
+ byteLength,
66
69
  caseFold,
67
70
  chathistoryReducer,
68
71
  extractLabel,
@@ -91,6 +94,7 @@ import {
91
94
  } from '@serverless-ircd/irc-core';
92
95
  import { dispatch } from './dispatch.js';
93
96
  import type { DispatchOptions } from './dispatch.js';
97
+ import { parseErrorReason, truncateToken } from './redact.js';
94
98
  import { type RoutedReducers, buildRoutedReducers } from './routing.js';
95
99
  import type { IrcRuntime } from './runtime.js';
96
100
  import { type Transport, WsTextFrameTransport } from './transport.js';
@@ -205,6 +209,19 @@ export interface ConnectionActorOptions {
205
209
  * watched nick later connects or quits.
206
210
  */
207
211
  readonly monitor?: MonitorStore;
212
+ /**
213
+ * Per-IP failed-`OPER` counter shared across every connection's actor
214
+ * (absent when the deployment's runtime does not maintain one — e.g.
215
+ * per-connection Durable Object adapters). When bound, `ctx.operFailures`
216
+ * is defined so the OPER reducer throttles brute-force guessing per
217
+ * source host: after `serverConfig.oper.maxFailures` credential
218
+ * failures within `oper.failureWindowSeconds`, further attempts from
219
+ * that host are rejected with `491` before the credential store is
220
+ * consulted and a `BroadcastOperNotice` notifies every oper. The
221
+ * instance MUST share the actor's clock so window arithmetic is
222
+ * coherent.
223
+ */
224
+ readonly operFailures?: OperFailureStats;
208
225
  /**
209
226
  * Network-statistics source for the `LUSERS` and `STATS` commands
210
227
  * (absent when no stats backend is wired). When bound, the actor
@@ -265,6 +282,7 @@ export class ConnectionActor {
265
282
  private readonly away: AwayStore | undefined;
266
283
  private readonly services: ServicesStore | undefined;
267
284
  private readonly monitor: MonitorStore | undefined;
285
+ private readonly operFailures: OperFailureStats | undefined;
268
286
  private readonly stats: ServerStats | undefined;
269
287
  private readonly reducers: RoutedReducers;
270
288
  /**
@@ -290,9 +308,10 @@ export class ConnectionActor {
290
308
  * In-progress IRCv3 `draft/multiline` batch, if any. Set when the client
291
309
  * sends `BATCH +id draft/multiline :<target>` (and the connection
292
310
  * negotiated the cap), populated by each intervening `PRIVMSG`/`NOTICE`,
293
- * and cleared when the closing `BATCH -id` is finalized. While set, the
294
- * actor diverts `PRIVMSG`/`NOTICE` into the accumulator instead of
295
- * running the normal channel/user reducers.
311
+ * and cleared when the closing `BATCH -id` is finalized or earlier on
312
+ * an incremental-budget abort or connection teardown ({@link dispose}).
313
+ * While set, the actor diverts `PRIVMSG`/`NOTICE` into the accumulator
314
+ * instead of running the normal channel/user reducers.
296
315
  */
297
316
  private openMultilineBatch:
298
317
  | {
@@ -318,6 +337,7 @@ export class ConnectionActor {
318
337
  this.away = opts.away;
319
338
  this.services = opts.services;
320
339
  this.monitor = opts.monitor;
340
+ this.operFailures = opts.operFailures;
321
341
  this.stats = opts.stats;
322
342
  this.logger = opts.logger ?? NoopLoggerInstance;
323
343
  this.pinnedTraceId = opts.traceId;
@@ -374,23 +394,41 @@ export class ConnectionActor {
374
394
  await this.receive(text);
375
395
  }
376
396
 
397
+ /**
398
+ * Connection teardown hook. Clears any state that must not outlive the
399
+ * connection — currently an abandoned `draft/multiline` batch (a client
400
+ * that disconnects between `BATCH +id` and `BATCH -id` would otherwise
401
+ * leave the accumulator mid-flight). Adapters call this when the
402
+ * underlying socket closes; safe to call at any lifecycle point.
403
+ */
404
+ dispose(): void {
405
+ this.openMultilineBatch = undefined;
406
+ }
407
+
377
408
  private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
378
409
  let msg: IrcMessage;
379
410
  let parseFailed = false;
411
+ let parseErr: unknown;
380
412
  try {
381
413
  msg = parse(line);
382
- } catch {
414
+ } catch (err) {
383
415
  // `parse` only throws `IrcParseError` (every throw inside the parser
384
416
  // constructs one); the untyped catch is the strict-mode narrowing
385
417
  // artifact for `unknown`. Every parse failure becomes a `421` so the
386
418
  // actor never crashes on a single bad line (acceptance criteria).
387
419
  parseFailed = true;
420
+ parseErr = err;
388
421
  msg = { tags: {}, command: '', params: [] };
389
422
  }
390
423
  if (parseFailed) {
424
+ // The raw line is NEVER logged: a hostile client can smuggle a
425
+ // credential past the parser (e.g. a malformed line whose payload
426
+ // carries `PASS <secret>`). Operators get the leading token
427
+ // (capped), the length, and a stable reason code instead.
391
428
  log.warn('frame.parse-error', {
392
- line,
393
- token: firstToken(line),
429
+ token: truncateToken(firstToken(line)),
430
+ length: line.length,
431
+ reason: parseErrorReason(parseErr),
394
432
  });
395
433
  const token = firstToken(line);
396
434
  await dispatch(
@@ -502,6 +540,7 @@ export class ConnectionActor {
502
540
  ...(this.history !== undefined ? { history: this.history } : {}),
503
541
  ...(this.away !== undefined ? { away: this.away } : {}),
504
542
  ...(this.services !== undefined ? { services: this.services } : {}),
543
+ ...(this.operFailures !== undefined ? { operFailures: this.operFailures } : {}),
505
544
  ...(label !== undefined ? { label } : {}),
506
545
  });
507
546
 
@@ -591,6 +630,7 @@ export class ConnectionActor {
591
630
  return this.routeMultiChannel(
592
631
  msg,
593
632
  (chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
633
+ ctx,
594
634
  );
595
635
  case 'TOPIC':
596
636
  return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
@@ -1109,6 +1149,12 @@ export class ConnectionActor {
1109
1149
  * `draft/multiline` batch. The inner line's target must match the batch
1110
1150
  * target and (after the first line) its command must match too; a
1111
1151
  * mismatch aborts the batch with a `FAIL` and clears the accumulator.
1152
+ *
1153
+ * The byte budget (`multilineMaxBytes`) and entry ceiling
1154
+ * (`multilineMaxEntries`) are enforced incrementally on each push: the
1155
+ * line that would push the batch past either limit aborts it immediately
1156
+ * (synthesized `BATCH -id` + `FAIL`) instead of being buffered for a
1157
+ * close the client may never send.
1112
1158
  */
1113
1159
  private accumulateMultilineLine(msg: IrcMessage): EffectType[] {
1114
1160
  const open = this.openMultilineBatch;
@@ -1141,10 +1187,46 @@ export class ConnectionActor {
1141
1187
  ]),
1142
1188
  ];
1143
1189
  }
1190
+ // Incremental budget enforcement: the byte budget and entry ceiling are
1191
+ // checked on every push, not just on close, so a client cannot buffer
1192
+ // unbounded content in a batch it never closes.
1193
+ const maxEntries = this.serverConfig.multilineMaxEntries ?? DEFAULT_MULTILINE_MAX_ENTRIES;
1194
+ if (open.entries.length >= maxEntries) {
1195
+ return this.abortMultilineBatch(open, 'MULTILINE_MAX_LINES', maxEntries, 'line');
1196
+ }
1197
+ const maxBytes = this.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
1198
+ const candidateBytes =
1199
+ multilineByteLength(open.entries) + (open.entries.length === 0 ? 0 : 1) + byteLength(text);
1200
+ if (candidateBytes > maxBytes) {
1201
+ return this.abortMultilineBatch(open, 'MULTILINE_MAX_BYTES', maxBytes, 'byte');
1202
+ }
1144
1203
  open.entries.push({ text });
1145
1204
  return [];
1146
1205
  }
1147
1206
 
1207
+ /**
1208
+ * Aborts an open `draft/multiline` batch that just violated the
1209
+ * incremental budget: clears the accumulator and emits a synthesized
1210
+ * `BATCH -id` close (so batch-aware clients exit the batch) followed by
1211
+ * the `FAIL` rejection carrying the limit that was exceeded.
1212
+ */
1213
+ private abortMultilineBatch(
1214
+ open: { readonly id: string; readonly target: string },
1215
+ code: 'MULTILINE_MAX_BYTES' | 'MULTILINE_MAX_LINES',
1216
+ limit: number,
1217
+ unit: 'byte' | 'line',
1218
+ ): EffectType[] {
1219
+ this.openMultilineBatch = undefined;
1220
+ return [
1221
+ Effect.send(this.state.id, [
1222
+ { text: `BATCH -${open.id}` },
1223
+ {
1224
+ text: `:${this.serverConfig.serverName} FAIL BATCH ${code} ${open.target} :Multi-line batch exceeds the ${limit} ${unit} limit`,
1225
+ },
1226
+ ]),
1227
+ ];
1228
+ }
1229
+
1148
1230
  /**
1149
1231
  * Finalizes an open `draft/multiline` batch on `BATCH -id`. Channel targets
1150
1232
  * delegate to the pure {@link finalizeMultilineBatch} reducer (cap-split
@@ -1310,6 +1392,7 @@ export class ConnectionActor {
1310
1392
  private routeMultiChannel(
1311
1393
  msg: IrcMessage,
1312
1394
  run: (chan: ChannelState, singleParamMsg: IrcMessage) => EffectType[],
1395
+ ctx: ReturnType<typeof buildCtx>,
1313
1396
  ): EffectType[] {
1314
1397
  const effects: EffectType[] = [];
1315
1398
  const rawParam = msg.params[0] ?? '';
@@ -1322,11 +1405,19 @@ export class ConnectionActor {
1322
1405
  // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
1323
1406
  return run(this.channels.getOrCreateChannel(''), msg);
1324
1407
  }
1325
- for (const name of names) {
1408
+ // Target cap (e.g. `PART #a,#b,#c,…`): only the first
1409
+ // `maxTargetsPerCommand` channels are processed; every dropped
1410
+ // channel gets a `407 ERR_TOOMANYTARGETS` so the client can see the
1411
+ // truncation instead of the part being silently ignored.
1412
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
1413
+ for (const name of names.slice(0, cap)) {
1326
1414
  const chan = this.channels.getOrCreateChannel(name);
1327
1415
  const singleParamMsg: IrcMessage = { ...msg, params: [name, ...tail] };
1328
1416
  effects.push(...run(chan, singleParamMsg));
1329
1417
  }
1418
+ for (const dropped of names.slice(cap)) {
1419
+ effects.push(this.tooManyTargetsEffect(dropped, ctx));
1420
+ }
1330
1421
  return effects;
1331
1422
  }
1332
1423
 
@@ -1356,7 +1447,12 @@ export class ConnectionActor {
1356
1447
  // proper 411 ERR_NORECIPIENT rather than silently doing nothing.
1357
1448
  return userReducer(this.state, msg, ctx).effects;
1358
1449
  }
1359
- for (const target of targets) {
1450
+ // Target cap (e.g. `PRIVMSG #a,#b,#c,…`): only the first
1451
+ // `maxTargetsPerCommand` targets are routed; every dropped target
1452
+ // gets a `407 ERR_TOOMANYTARGETS` so the client can see the
1453
+ // truncation instead of the message being silently ignored.
1454
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
1455
+ for (const target of targets.slice(0, cap)) {
1360
1456
  const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
1361
1457
  const serviceReducer = this.serviceReducerFor(target, msg.command);
1362
1458
  if (isChannelTarget(target)) {
@@ -1378,9 +1474,28 @@ export class ConnectionActor {
1378
1474
  effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
1379
1475
  }
1380
1476
  }
1477
+ for (const dropped of targets.slice(cap)) {
1478
+ effects.push(this.tooManyTargetsEffect(dropped, ctx));
1479
+ }
1381
1480
  return effects;
1382
1481
  }
1383
1482
 
1483
+ /**
1484
+ * Builds the `407 ERR_TOOMANYTARGETS` Send effect for one target dropped
1485
+ * by the `maxTargetsPerCommand` cap in {@link routeMessageTarget} /
1486
+ * {@link routeMultiChannel}. Emitted once per dropped target after the
1487
+ * allowed targets have been routed, so the client learns exactly which
1488
+ * targets were not delivered to.
1489
+ */
1490
+ private tooManyTargetsEffect(target: string, ctx: ReturnType<typeof buildCtx>): EffectType {
1491
+ const nick = ctx.connection.nick ?? '*';
1492
+ const code = Numerics.ERR_TOOMANYTARGETS.toString().padStart(3, '0');
1493
+ const line: RawLine = {
1494
+ text: `:${this.serverConfig.serverName} ${code} ${nick} ${target} :Too many targets`,
1495
+ };
1496
+ return Effect.send(ctx.connId, [line]);
1497
+ }
1498
+
1384
1499
  /**
1385
1500
  * Resolves the service pseudo-client reducer for a nick target, or
1386
1501
  * `undefined` when the target is not a service nick / the command is not
@@ -53,6 +53,7 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
53
53
  ReleaseNick: (e, r, m) => releaseNickEtc(e, r, m),
54
54
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
55
55
  BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
56
+ BroadcastOperNotice: (e, r) => r.broadcastOperNotice(e.lines, e.except),
56
57
  EnforceNick: (e, r, _m, opts) => enforceNick(e, r, opts),
57
58
  };
58
59
 
@@ -19,3 +19,10 @@ export {
19
19
  export type { Transport } from './transport.js';
20
20
  export { buildRoutedReducers, resolveFloodControl } from './routing.js';
21
21
  export type { RoutedReducers } from './routing.js';
22
+ export {
23
+ MAX_LOG_TOKEN_CHARS,
24
+ parseErrorReason,
25
+ REDACTED,
26
+ redactLine,
27
+ truncateToken,
28
+ } from './redact.js';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Log-scrubbing helpers — the credential-exfiltration guards for log
3
+ * payloads.
4
+ *
5
+ * Threat model: a hostile client can deliberately smuggle secret
6
+ * material into server logs by sending a line whose payload carries a
7
+ * credential (e.g. `PASS hunter2` inside a frame that trips the parser).
8
+ * Anything derived from inbound client input that reaches a warn/error
9
+ * log record must pass through these helpers first:
10
+ *
11
+ * - A site that must log a line logs `redactLine(line)` — the command
12
+ * verb (and NickServ target + subcommand) survive for debuggability;
13
+ * the credential-bearing parameters are replaced with `[REDACTED]`.
14
+ * - Parse diagnostics never log the line at all: they log
15
+ * `truncateToken(firstToken(line))` + the line length +
16
+ * `parseErrorReason(err)` so operators can correlate without ever
17
+ * storing client payload.
18
+ */
19
+
20
+ /** Placeholder substituted for redacted credential material. */
21
+ export const REDACTED = '[REDACTED]';
22
+
23
+ /**
24
+ * Max characters of a leading token included in log records. Caps the
25
+ * volume a hostile client can push into logs with a single over-long
26
+ * garbage token (the wire itself allows 512 bytes per line).
27
+ */
28
+ export const MAX_LOG_TOKEN_CHARS = 32;
29
+
30
+ /**
31
+ * Command verbs whose parameters are credential material. `IDENTIFY`
32
+ * covers the service-shortcut form some clients send as a top-level
33
+ * command; the PRIVMSG/NOTICE-to-NickServ form is handled separately
34
+ * below.
35
+ */
36
+ const SENSITIVE_COMMANDS: ReadonlySet<string> = new Set([
37
+ 'PASS',
38
+ 'AUTHENTICATE',
39
+ 'OPER',
40
+ 'IDENTIFY',
41
+ ]);
42
+
43
+ /**
44
+ * rfc1459-lowercased service nick targets for PRIVMSG/NOTICE. Only
45
+ * messages aimed at these nicks are inspected for credential
46
+ * subcommands, so ordinary chat is never touched.
47
+ */
48
+ const SERVICE_NICKS: ReadonlySet<string> = new Set(['nickserv', 'ns']);
49
+
50
+ /**
51
+ * NickServ subcommands whose arguments are credentials (`IDENTIFY
52
+ * <password>`, the `ID <password>` alias). Anything else aimed at a
53
+ * service nick (INFO, HELP, …) is not secret and passes through.
54
+ */
55
+ const SENSITIVE_SUBCOMMANDS: ReadonlySet<string> = new Set(['identify', 'id']);
56
+
57
+ /**
58
+ * Redacts the credential-bearing parameters of a raw inbound IRC line
59
+ * while keeping the command verb (and, for service messages, the target
60
+ * and subcommand) for debuggability. Non-sensitive lines are returned
61
+ * unchanged; the input is never lengthened beyond a few placeholder
62
+ * characters.
63
+ *
64
+ * Sensitive shapes:
65
+ * `PASS <pw>` → `PASS [REDACTED]`
66
+ * `AUTHENTICATE <sasl-payload>` → `AUTHENTICATE [REDACTED]`
67
+ * `OPER <name> <pw>` → `OPER [REDACTED]`
68
+ * `IDENTIFY <pw>` → `IDENTIFY [REDACTED]`
69
+ * `PRIVMSG NickServ :IDENTIFY <pw>`→ `PRIVMSG NickServ :IDENTIFY [REDACTED]`
70
+ * (same for NOTICE, the `ID` alias, `ns`, and missing trailing colons)
71
+ *
72
+ * An optional leading `:source` prefix is preserved verbatim.
73
+ */
74
+ export function redactLine(line: string): string {
75
+ // Split off an optional leading source prefix; the next token is the
76
+ // command position.
77
+ let head = '';
78
+ let rest = line;
79
+ if (line.startsWith(':')) {
80
+ const prefixEnd = line.indexOf(' ');
81
+ if (prefixEnd === -1) return line;
82
+ head = line.slice(0, prefixEnd);
83
+ rest = line.slice(prefixEnd + 1);
84
+ }
85
+
86
+ const commandEnd = rest.indexOf(' ');
87
+ const command = commandEnd === -1 ? rest : rest.slice(0, commandEnd);
88
+ const remainder = commandEnd === -1 ? '' : rest.slice(commandEnd + 1);
89
+ const verb = command.toUpperCase();
90
+
91
+ if (SENSITIVE_COMMANDS.has(verb)) {
92
+ if (remainder.length === 0) return line;
93
+ return withPrefix(head, `${command} ${REDACTED}`);
94
+ }
95
+
96
+ if (verb === 'PRIVMSG' || verb === 'NOTICE') {
97
+ const targetEnd = remainder.indexOf(' ');
98
+ const target = targetEnd === -1 ? remainder : remainder.slice(0, targetEnd);
99
+ const body = targetEnd === -1 ? '' : remainder.slice(targetEnd + 1);
100
+ if (SERVICE_NICKS.has(target.toLowerCase())) {
101
+ // Tolerate both conforming (`:IDENTIFY pw`) and colon-less bodies.
102
+ const bare = body.startsWith(':') ? body.slice(1) : body;
103
+ const hadColon = bare.length !== body.length;
104
+ const subEnd = bare.indexOf(' ');
105
+ const sub = subEnd === -1 ? bare : bare.slice(0, subEnd);
106
+ const hasArgs = subEnd !== -1;
107
+ if (hasArgs && SENSITIVE_SUBCOMMANDS.has(sub.toLowerCase())) {
108
+ return withPrefix(head, `${command} ${target} ${hadColon ? ':' : ''}${sub} ${REDACTED}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ return line;
114
+ }
115
+
116
+ /**
117
+ * Caps a leading token at {@link MAX_LOG_TOKEN_CHARS} characters,
118
+ * suffixing an ellipsis marker when truncation occurred. The marker makes
119
+ * truncated tokens visually distinct from tokens that were exactly at the
120
+ * cap.
121
+ */
122
+ export function truncateToken(token: string): string {
123
+ return token.length <= MAX_LOG_TOKEN_CHARS ? token : `${token.slice(0, MAX_LOG_TOKEN_CHARS)}...`;
124
+ }
125
+
126
+ /**
127
+ * Parser diagnostics (fixed strings or fixed prefixes) → stable reason
128
+ * codes. Codes are log query keys; they must stay stable even if the
129
+ * human-readable message is reworded.
130
+ */
131
+ const PARSE_ERROR_REASONS: ReadonlyArray<readonly [prefix: string, reason: string]> = [
132
+ ['empty message', 'empty-message'],
133
+ ['message exceeds', 'input-cap-exceeded'],
134
+ ['tag section exceeds', 'tag-section-too-large'],
135
+ ['tag section with no message body', 'tags-without-body'],
136
+ ['too many tags', 'too-many-tags'],
137
+ ['source prefix with no command', 'prefix-without-command'],
138
+ ['missing command', 'missing-command'],
139
+ ['invalid command token', 'invalid-command-token'],
140
+ ];
141
+
142
+ /**
143
+ * Maps a thrown parse error to a stable reason code for log records.
144
+ * `parse` only throws `IrcParseError` (every throw inside the parser
145
+ * constructs one), but the guard accepts any thrown value so callers can
146
+ * pass an `unknown` catch binding directly.
147
+ */
148
+ export function parseErrorReason(err: unknown): string {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ for (const [prefix, reason] of PARSE_ERROR_REASONS) {
151
+ if (message.startsWith(prefix)) return reason;
152
+ }
153
+ return 'parse-failed';
154
+ }
155
+
156
+ /** Re-attaches the (possibly absent) source prefix to a redacted tail. */
157
+ function withPrefix(head: string, tail: string): string {
158
+ return head.length === 0 ? tail : `${head} ${tail}`;
159
+ }
@@ -54,6 +54,20 @@ export interface IrcRuntime {
54
54
  * oper's WALLOPS rate.
55
55
  */
56
56
  broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void>;
57
+ /**
58
+ * Oper-only notice fanout. Delivers `lines` to every live connection
59
+ * whose `+o` (oper) user mode is set, skipping `except`. Emitted by the
60
+ * OPER reducer's per-IP brute-force lockout gate
61
+ * (`:<server> NOTICE * :OPER lockout triggered for <host>`) so the oper
62
+ * corps learns a guessing attack is being throttled. Enumeration
63
+ * strategy mirrors {@link broadcastWallops} per adapter:
64
+ * - in-memory: scans its connection map, filters `+o`.
65
+ * - Cloudflare: walks the sharded registry, fetches each state to
66
+ * check `+o`.
67
+ * - AWS: `Scan`s the `Connections` table filtered to `+o` rows and
68
+ * `PostToConnection`s each.
69
+ */
70
+ broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void>;
57
71
 
58
72
  // nickname registry
59
73
  reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
@@ -15,7 +15,7 @@
15
15
  * parser/reducer/dispatch pipeline.
16
16
  */
17
17
 
18
- import { splitFrameLines } from '@serverless-ircd/irc-core';
18
+ import { MAX_BUFFER_BYTES, byteLength, splitFrameLines } from '@serverless-ircd/irc-core';
19
19
 
20
20
  export { splitFrameLines };
21
21
 
@@ -28,6 +28,22 @@ export interface Transport {
28
28
  feed(chunk: string): string[];
29
29
  }
30
30
 
31
+ /** Options accepted by {@link TcpByteStreamTransport}. */
32
+ export interface TcpByteStreamTransportOptions {
33
+ /**
34
+ * Invoked when an appended chunk grows the retained partial-line buffer
35
+ * past {@link MAX_BUFFER_BYTES} (measured in UTF-8 bytes, once per
36
+ * append). The transport drops the oversized tail and returns no lines;
37
+ * the owner should close the connection — a peer that streams more than
38
+ * 8 KiB without a line terminator can never produce a legal IRC line
39
+ * (the parser caps lines at 512 bytes), so the buffer is pure
40
+ * attacker-controlled memory. Writing `ERROR :Closing link: ...` and
41
+ * tearing down the socket is the caller's job: this seam has no write
42
+ * path.
43
+ */
44
+ onOverflow?: () => void;
45
+ }
46
+
31
47
  /**
32
48
  * Splits a WebSocket text frame into IRC lines. Re-exported from
33
49
  * `@serverless-ircd/irc-core` (`ws-framing.ts`), which owns the canonical
@@ -65,6 +81,8 @@ export class WsTextFrameTransport implements Transport {
65
81
  export class TcpByteStreamTransport implements Transport {
66
82
  private buffer = '';
67
83
 
84
+ constructor(private readonly options?: TcpByteStreamTransportOptions) {}
85
+
68
86
  feed(chunk: string): string[] {
69
87
  if (chunk.length === 0) return [];
70
88
  this.buffer += chunk;
@@ -80,6 +98,15 @@ export class TcpByteStreamTransport implements Transport {
80
98
  }
81
99
  }
82
100
  this.buffer = this.buffer.slice(start);
101
+ // Cap the retained (unterminated) tail — checked once per append, in
102
+ // UTF-8 bytes. Complete lines flushed by this chunk never count against
103
+ // the budget, so legitimate multi-line batches far larger than the cap
104
+ // pass through; only an unterminated tail can grow unboundedly.
105
+ if (byteLength(this.buffer) > MAX_BUFFER_BYTES) {
106
+ this.buffer = '';
107
+ this.options?.onOverflow?.();
108
+ return [];
109
+ }
83
110
  return lines;
84
111
  }
85
112